Skip to content

Auditing

Overview

Application need to provide such capabilities as:

  • record events
  • intercept and record errors
  • track page and api load time

Audit data needs to be collected and sent to api in bulk.

API

For auditing purposes add post_audit method.

  1. Package /database/app/pck_app.pks specification.
plsql
--
    PROCEDURE post_audit( -- Procedure logs user activity (PUBLIC)
        p_data CLOB --  Audit data in JSON format [{severity, action, details, created}]
    );
--
  1. Package /database/app/pck_app.pks body.
plsql
--
    PROCEDURE post_audit(
        p_data CLOB
    ) AS
        v_uuid app_users.uuid%TYPE := pck_api_auth.uuid;
    BEGIN
        IF v_uuid IS NULL THEN
            pck_api_audit.wrn('Audit error', 'User not authenticated');
        END IF;

        pck_api_audit.audit(p_data, v_uuid);

    END;
--
  1. Add method to @/api/index.ts
ts
// ...
export type AuditData = {
  severity: string
  action: string
  details?: string
  created?: string
}
// ...
  async audit(data: AuditData[]): Promise<HttpResponse<void>> {
    return await http.post('audit/', {data : JSON.stringify(data) })
  },
// ...

Store

  1. Create new store for processing audit records

Store will provide methods inf, wrn, err as well as handling of auto save of audit records.

@/stores/app/audit.ts
ts
import { defineStore, acceptHMRUpdate } from 'pinia'
import { appApi, type AuditData } from '@/api'

export const useAuditStore = defineStore('audit', () => {
  let autoSaveTimer: number | null = null

  let initialData: AuditData[] = []
  try {
    const stored = localStorage.getItem('audit')
    if (stored) initialData = JSON.parse(stored)
  } catch {
    initialData = []
  }

  const data = ref<AuditData[]>(initialData)

  const count = computed(() => data.value.length)

  const log = async (
    severity: string,
    action: string,
    details: string,
    saveImmediately = false,
  ) => {
    data.value.push({ severity, action, details, created: new Date().toISOString() })
    if (saveImmediately) await save()
  }

  async function inf(action: string, details: string, saveImmediately = false) {
    log('I', action, details, saveImmediately)
  }

  async function wrn(action: string, details: string, saveImmediately = false) {
    log('W', action, details, saveImmediately)
  }

  async function err(action: string, details: string, saveImmediately = false) {
    log('E', action, details, saveImmediately)
  }

  async function save() {
    if (data.value.length) {
      try {
        await appApi.audit(data.value)
      } catch (error) {
        err('Failed to save audit log', (error as Error).message)
        if (import.meta.env.DEV) console.error('Failed to save audit log', data.value)
      } finally {
        data.value = []
      }
    }
  }

  function startAutoSave() {
    if (!autoSaveTimer) {
      autoSaveTimer = window.setInterval(save, 60000)
    }
  }

  function stopAutoSave() {
    if (autoSaveTimer) {
      clearInterval(autoSaveTimer)
      autoSaveTimer = null
    }
  }

  onMounted(async () => {
    startAutoSave()
  })

  onUnmounted(async () => {
    await save()
    stopAutoSave()
  })

  watch(
    () => data.value,
    (newData) => {
      try {
        localStorage.setItem('audit', JSON.stringify(newData))
      } finally {
        // do nothing
      }
    },
    { deep: true },
  )

  return {
    inf,
    wrn,
    err,
    count,
    save,
    startAutoSave,
    stopAutoSave,
  }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useAuditStore, import.meta.hot))
}
  1. Append the new audit store to @/store/index.ts
ts
// ...
import { useAuditStore } from './app/audit'
// ...
export const useAppStore = defineStore('app', () => {
  const audit = useAuditStore()
// ...
  return {
    audit,
// ...
  }

Error intercpetion

  1. Create error handler for interception of errors.
@/plugins/errors.ts
ts
import type { ComponentPublicInstance } from 'vue'

type StackTraceElement = {
  functionName: string | null
  fileName: string | null
  lineNumber: number | null
  columnNumber: number | null
}

function parseStackTrace(stack: string): StackTraceElement[] {
  const stackLines = stack.split('\n')
  const stackTrace: StackTraceElement[] = []

  const chromeFirefoxRegex = /at\s+(.*?)\s+\(?(.+?):(\d+):(\d+)\)?/
  const edgeRegex = /^\s*([^\s]+)@(.+?):(\d+):(\d+)$/
  const safariRegex = /(\w+@\S+):(\d+):(\d+)/

  for (const line of stackLines) {
    let match

    if ((match = line.match(chromeFirefoxRegex))) {
      const functionName = match[1] !== 'anonymous' ? match[1] : null
      const fileName = match[2]
      const lineNumber = parseInt(match[3], 10)
      const columnNumber = parseInt(match[4], 10)

      stackTrace.push({ functionName, fileName, lineNumber, columnNumber })
    } else if ((match = line.match(edgeRegex))) {
      const functionName = match[1] !== 'anonymous' ? match[1] : null
      const fileName = match[2]
      const lineNumber = parseInt(match[3], 10)
      const columnNumber = parseInt(match[4], 10)

      stackTrace.push({ functionName, fileName, lineNumber, columnNumber })
    } else if ((match = line.match(safariRegex))) {
      const functionName = null
      const fileName = match[1]
      const lineNumber = parseInt(match[2], 10)
      const columnNumber = parseInt(match[3], 10)

      stackTrace.push({ functionName, fileName, lineNumber, columnNumber })
    }
  }

  return stackTrace
}

export const errorHandler = function (
  err: unknown,
  instance: ComponentPublicInstance | null,
  info: string,
) {
  if (import.meta.env.MODE !== 'production') console.error(err, instance, info)

  let errorMessage = ''
  let stackTrace = [] as StackTraceElement[]

  if (err instanceof Error) {
    errorMessage = err.message
    if (err.stack) {
      stackTrace = parseStackTrace(err.stack)
    }
  } else if (typeof err === 'string') {
    errorMessage = err
  } else {
    errorMessage = JSON.stringify(err)
  }

  const instanceName = instance?.$options.name || 'unknown component'
  const instanceProps = instance?.$props || {}

  let errorDetails = ''
  try {
    errorDetails = JSON.stringify(
      {
        error: errorMessage,
        instance: {
          name: instanceName,
          props: instanceProps,
        },
        info: info,
        stackTrace: stackTrace,
      },
      null,
      '\t',
    )
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error'
    errorDetails = `Failed to stringify error details: ${errorMessage}`
  }

  const auditStore = useAuditStore()
  auditStore.err(errorMessage, errorDetails)
}
  1. Add error handler to @/main.ts
ts
// ...
import { errorHandler } from './plugins/errors'

const app = createApp(App)
app.config.errorHandler = errorHandler
// ...

From now on any Vue error will be passed to common audit store and preserved and sent to backend.

Performance - page loads

  1. Create threshold parameter, e.g. 250 ms, in ./.env.production and ./.env.development.local to audit cases where page loads longer that the set threshold.
ini
VITE_PERFORMANCE_PAGE_LOAD_THRESHOLD_IN_MS = 250
  1. Add logic in @/router/index.ts to track this information
ts
// ...
router.beforeEach(async (to) => {
  to.meta.performance = performance.now()
// ...
})

router.afterEach((to) => {
  const duration: number = performance.now() - (to.meta.performance as number)
  if (duration >= import.meta.env.VITE_PERFORMANCE_PAGE_LOAD_THRESHOLD_IN_MS) {
   const appAudit = useAuditStore()
   appAudit.wrn('Page load time threshold exceeded', `Route ${to.path} took ${duration}ms`)
  }
})
// ...

Performance - API calls

  1. Create threshold parameter, e.g. 250 ms, in ./.env.production and ./.env.development.local to audit cases where API calls take longer that the set threshold.
ini
VITE_PERFORMANCE_API_CALL_THRESHOLD_IN_MS = 250
  1. Add logic in HTTP interceptors to detect slow API calls in @\composables\http.ts
ts
// ...
  instance.interceptors.request.use(
    (config) => {
      config.headers['request-startTime'] = performance.now()
// ...
      return config
    },
    (error) => Promise.reject(error)
  )

  instance.interceptors.response.use(
    (response) => {
      // ...
      const duration = performance.now() - (response.config.headers['request-startTime'] as number)
      if (duration >= import.meta.env.VITE_PERFORMANCE_API_CALL_THRESHOLD_IN_MS) {
        const appAudit = useAuditStore()
        appAudit.wrn('API call time threshold exceeded', `API ${response.config.url} took ${duration}ms`)
      }
      return response
    },
// ...

Usage

Add audit test card to @/pages/sandbox/index.ts

vue
  <v-card class="mt-6">
    <v-card-title>Test audit</v-card-title>
    <v-card-text
      >Test audit capabilities. In stash: <strong>{{ appStore.audit.count }}</strong></v-card-text
    >
    <v-card-actions>
      <v-btn
        color="info"
        @click="appStore.audit.inf('This is info message', 'This is info message details')"
        >Test info</v-btn
      >
      <v-btn
        color="warning"
        @click="appStore.audit.wrn('This is warning message', 'This is warning message details')"
        >Test warning</v-btn
      >
      <v-btn color="error" @click="error()">Test error</v-btn>
      <v-spacer></v-spacer>
      <v-btn @click="appStore.audit.save()">Save</v-btn>
    </v-card-actions>
  </v-card>
</template>

<script setup lang="ts">
...
// AUDIT
const appStore = useAppStore()
function error() {
  throw new Error('This is an error')
}
</script>