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.

./db/src/database/odbvue/package_specs/pck_app.sql ​

plsql
--
    PROCEDURE post_audit( -- Procedure to log audit events
        p_data IN CLOB -- Audit data [{severity, message, attributes, created}]
    );
--

./db/src/database/odbvue/package_bodies/pck_app.sql ​

plsql
--
    PROCEDURE post_audit( 
        p_data IN CLOB 
    ) AS
    BEGIN
        pck_api_audit.bulk(p_data);
    END post_audit;
--

Store ​

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

@/stores/app/audit.ts ​

source
ts
import { computed, ref, watch } from 'vue'
import { defineStore, acceptHMRUpdate } from 'pinia'
import { useHttp } from '@/composables/http'
import { useAppStore } from '../index'

type AuditData = {
  severity: string
  message: string
  attributes?: string | Record<string, unknown>
  created?: string
}

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,
    message: string,
    attributes: string | Record<string, unknown> = '',
    saveImmediately = false,
  ) => {
    data.value.push({ severity, message, attributes, created: new Date().toISOString() })
    if (saveImmediately) await save()
  }

  async function inf(
    message: string,
    attributes: string | Record<string, unknown> = '',
    saveImmediately = false,
  ) {
    log('INFO', message, attributes, saveImmediately)
  }

  async function wrn(
    message: string,
    attributes: string | Record<string, unknown> = '',
    saveImmediately = false,
  ) {
    log('WARN', message, attributes, saveImmediately)
  }

  async function err(message: string, attributes: string, saveImmediately = false) {
    log('ERROR', message, attributes, saveImmediately)
  }

  function attrs(data: AuditData[]) {
    for (const item of data) {
      if (item.attributes && typeof item.attributes === 'string') {
        item.attributes = { data: item.attributes }
      }
      const app = useAppStore()
      if (app.user && app.user.uuid) {
        item.attributes = {
          ...(item.attributes as Record<string, unknown>),
          uuid: app.user.uuid,
        }
      }
    }
    return data
  }

  async function save() {
    if (data.value.length) {
      try {
        const http = useHttp()
        await http.post('app/audit/', { data: JSON.stringify(attrs(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 = []
      }
    }
    const errors = JSON.parse(localStorage.getItem('errors') || '[]')
    if (errors.length) {
      try {
        const http = useHttp()
        await http.post('app/audit/', { data: JSON.stringify(attrs(errors)) })
      } catch (error) {
        err('Failed to save error log', (error as Error).message)
        if (import.meta.env.DEV) console.error('Failed to save error log', errors)
      } finally {
        localStorage.removeItem('errors')
      }
    }
  }

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

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

  watch(
    () => data.value,
    (newData: AuditData[]) => {
      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))
}

Append the new audit store to Main Store

@/store/index.ts ​

ts
// ...
import { useAuditStore } from './app/audit'
// ...
export const useAppStore = defineStore('app', () => {
    const getAudit = () => useAuditStore()
// ...
  return {
      audit: getAudit(),
// ...
  }
})

Error interceptor ​

Create error handler for interception of errors.

@/plugins/errors.ts ​

source
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] ?? null) !== 'anonymous' ? (match[1] ?? null) : 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] ?? null) !== 'anonymous' ? (match[1] ?? null) : 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,
) {
  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 errors = JSON.parse(localStorage.getItem('errors') || '[]')
  errors.push({
    severity: 'ERROR',
    message: errorMessage,
    attributes: errorDetails,
    created: new Date().toISOString(),
  })
  localStorage.setItem('errors', JSON.stringify(errors))
}

Enable error handler

@/main.ts ​

ts
// ...
import { errorHandler } from './plugins/errors'

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

Add auto save of audit logs

@/App.vue ​

ts
//..
onMounted(async () => {
  const app = useAppStore()
  await app.init()
  app.audit.startAutoSave()
})

onUnmounted(() => {
  const app = useAppStore()
  app.audit.stopAutoSave()
})
//..

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

Usage ​

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

vue
<template>
  <!-- // .. -->
  <v-container fluid>
    <h2 class="mb-4">Audit</h2>
    <v-row>
      <v-col cols="12">
        <v-card class="mt-6">
          <v-card-title>Test audit</v-card-title>
          <v-card-text
            >Test audit capabilities. In stash: <strong>{{ app.audit.count }}</strong></v-card-text
          >
          <v-card-actions>
            <v-btn
              color="info"
              @click="app.audit.inf('This is info message', 'This is info message details')"
              >Test info</v-btn
            >
            <v-btn
              color="warning"
              @click="app.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="app.audit.save()">Save</v-btn>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script setup lang="ts">

// ..
function error() {
  throw new Error('This is an error')
}
</script>