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.
- 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}]
);
--
- 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;
--
- 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
- 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))
}
- 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
- 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)
}
- 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
- 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
- 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
- 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
- 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>