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>