diff --git a/default.env b/default.env index 234a8f76..71047008 100644 --- a/default.env +++ b/default.env @@ -23,6 +23,13 @@ COMPOSE_FILE=docker-compose.yaml:docker-compose.static-ingress.yaml # Enable to use development overrides # COMPOSE_FILE=docker-compose.yaml:docker-compose.dev.yaml +# Version string for Logging +#VITE_VERSION_STRING= + +# Logging configuration +#VITE_LOG_URL= +#VITE_SYSTEM_URL= + # SHL Server API endpoint url VITE_API_BASE= diff --git a/src/env.d.ts b/src/env.d.ts index 2f6c0142..f2ba08ff 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -5,6 +5,8 @@ interface ImportMetaEnv { readonly VITE_EPIC_CLIENT_ID: string readonly VITE_CERNER_CLIENT_ID: string readonly VITE_API_BASE: string + readonly VITE_LOG_URL: string + readonly VITE_SYSTEM_URL: string readonly VITE_VIEWER_BASE: string readonly VITE_INTERMEDIATE_FHIR_SERVER_BASE: string readonly VITE_SOF_CLIENT_ID: string @@ -16,6 +18,7 @@ interface ImportMetaEnv { readonly VITE_OIDC_CHECK_SESSION_IFRAME: string readonly VITE_INACTIVITY_TIMEOUT: string readonly VITE_BACKUP_INACTIVITY_TIMEOUT: string + readonly VITE_VERSION_STRING: string readonly DEV_SERVER_PORT: number } diff --git a/src/lib/AddFileLTT.svelte b/src/lib/AddFileLTT.svelte index 130e65c5..fc7948dd 100644 --- a/src/lib/AddFileLTT.svelte +++ b/src/lib/AddFileLTT.svelte @@ -16,6 +16,7 @@ SHLRetrieveEvent, SHLSubmitEvent} from './types'; import RetrieveShl from './RetrieveSHL.svelte'; + import { log } from '$lib/logger'; let shlStore: Writable = getContext('shlStore'); let shlClient: SHLClient = getContext('shlClient'); @@ -98,6 +99,18 @@ let result = await sofClient.postShl(shl, mostRecentDocRef, reportLabel); $shlStore = shl; console.log($shlStore); + log({ + action: "create", + entity: { + detail: { + action: `Created SHL '${$shlStore.id}'`, + shl_session: $shlStore.sessionId ?? "", + shl: $shlStore.id, + patient: patient.id, + docref: mostRecentDocRef.id + } + } + }); shlReadyDispatch('shl-ready', true); } else { fetchError = "No Report found"; @@ -143,16 +156,50 @@ let reportLabel = `My Choices Report (${reportDate})`; let result = await sofClient.postShl($shlStore, mostRecentDocRef, reportLabel); $shlStore.label = result.label; + log({ + action: "create", + entity: { + detail: { + action: `Created new SHL for updated report '${$shlStore.id}'`, + shl_session: $shlStore.sessionId ?? "", + shl: $shlStore.id, + patient: patient.id, + docref: mostRecentDocRef.id + } + } + }); updatedShl = true; } // The current SHL is most recent, so use it shlReadyDispatch('shl-ready', true); } else if (mostRecentDocRef) { console.log(`Couldn't find FHIR record for SHL ${$shlStore.id} and session ${$shlStore.sessionId}, creating new SHL`); + log({ + action: "read", + entity: { + detail: { + action: `Recreating SHL for '${$shlStore.sessionId}'`, + shl_session: $shlStore.sessionId ?? "", + shl: $shlStore.id + } + } + }); shlClient.deleteShl($shlStore); newShl(patient, mostRecentDocRef); } else { fetchError = "No report found."; + log({ + action: "read", + severity: "error", + entity: { + detail: { + action: `No report found for '${$shlStore.sessionId}'`, + shl_session: $shlStore.sessionId ?? "", + shl: $shlStore.id, + patient: patient.id + } + } + }) } } @@ -169,13 +216,24 @@ async function updateShl(details: SHLRetrieveEvent) { shlResult = details; - if (shlResult.shl) { - // Trigger update in store - $shlStore = shlResult.shl; - } else { - createSHL = true; - } - checkedShl = true; + if (shlResult.shl) { + log({ + action: "read", + entity: { + detail: { + action: `Loaded SHL '${shlResult.shl.id}'`, + shl_session: sessionId, + shl: shlResult.shl.id, + patient: patientId + } + } + }); + // Trigger update in store + $shlStore = shlResult.shl; + } else { + createSHL = true; + } + checkedShl = true; } function createIpsPayload(patient:any, docref:any) { diff --git a/src/lib/CopyButton.svelte b/src/lib/CopyButton.svelte index 4d178b95..7e221866 100644 --- a/src/lib/CopyButton.svelte +++ b/src/lib/CopyButton.svelte @@ -3,9 +3,11 @@ Button, Icon } from 'sveltestrap'; + import { log } from '$lib/logger'; let copyNotice = ''; + export let id: string; export let href: string; async function copyShl() { @@ -17,6 +19,16 @@ setTimeout(() => { copyNotice = copyNoticePrev; }, 8000); + log({ + action: 'read', + entity: { + detail: { + action: `Copied SHL to clipboard`, + url: href, + Button: id + } + } + }) } {#if copyNotice} diff --git a/src/lib/FetchSoFLTT.svelte b/src/lib/FetchSoFLTT.svelte index 1a09c6ec..80152eba 100644 --- a/src/lib/FetchSoFLTT.svelte +++ b/src/lib/FetchSoFLTT.svelte @@ -4,6 +4,7 @@ import { createEventDispatcher, onMount } from 'svelte'; import { Alert } from 'sveltestrap'; import type { SOFClient } from './sofClient'; + import { log } from '$lib/logger'; const resourceDispatch = createEventDispatcher<{ updateResources: ResourceRetrieveEvent }>(); @@ -33,6 +34,16 @@ processing = false; return resourceDispatch('updateResources', result); } catch (e) { + log({ + action: "read", + severity: "error", + entity: { + detail: { + action: `Error while fetching Resources`, + error: `${JSON.stringify(e)}` + } + } + }); processing = false; console.error('Error while fetching data: ', e); fetchError = "Unable to find your Report."; @@ -43,6 +54,6 @@ {#if fetchError}

{fetchError}

- You can try again later, click "Back" to choose another option, or reach out for help below. + If you have completed your Choices Report, reach out for help below. Otherwise, click "Back" to finish your Report.
{/if} diff --git a/src/lib/HealthLinkLTT.svelte b/src/lib/HealthLinkLTT.svelte index 631adf73..ac66aff5 100644 --- a/src/lib/HealthLinkLTT.svelte +++ b/src/lib/HealthLinkLTT.svelte @@ -28,15 +28,24 @@ import { fade } from 'svelte/transition'; import type { Writable } from 'svelte/store'; import type { SHLAdminParams, SHLClient } from './managementClient'; + import { log } from '$lib/logger'; let open = false; - const toggle = () => (open = !open); + function toggle() { + open = !open + log({ + action: "read", + entity: { + detail: { + action: (open ? "Open" : "Close") + " SHL reset dialogue" + } + } + }) + }; let shlStore: Writable = getContext('shlStore'); let shlClient: SHLClient = getContext('shlClient'); - let copyNotice = ''; - let href: Promise; let qrCode: Promise; let exp: Date; @@ -77,24 +86,35 @@ encryptionKey: shl.encryptionKey, files: [] }; - return await shlClient.toLink(shlMin); - } - - async function copyShl() { - let copyNoticePrev = copyNotice; - copyNotice = '...'; - let text = await getUrl($shlStore); - navigator.clipboard.writeText(text); - copyNotice = 'Copied!'; - setTimeout(() => { - copyNotice = copyNoticePrev; - }, 1000); + let url = await shlClient.toLink(shlMin); + log({ + action: "read", + entity: { + detail: { + action: `Load SHL '${shl.id}'`, + shl_session: shl.sessionId ?? "", + shl: shl.id, + url: url + } + } + }) + return url; } async function deactivateShl() { toggle(); await shlClient.deleteShl($shlStore); // TODO: Implement post-deactivation flow + log({ + action: "update", + entity: { + detail: { + action: `User deactivated SHL '${$shlStore.id}'`, + shl_session: $shlStore.sessionId ?? "", + shl: $shlStore.id + } + } + }); location.reload(); } @@ -103,8 +123,26 @@ function updateInstructions(event) { if (instructions && event.target.id.includes(instructions)) { instructions = ""; + log({ + action: "update", + entity: { + detail: { + action: `Closed sharing instructions`, + id: event.target.id + } + } + }); } else { instructions = event.target.id.split('-')[0]; + log({ + action: "update", + entity: { + detail: { + action: `Opened ${instructions} sharing instructions`, + id: event.target.id + } + } + }); } } @@ -178,7 +216,7 @@ {#await href then href} - + {/await} @@ -204,7 +242,7 @@ {#await href then href} - + {/await} @@ -276,7 +314,7 @@ - + {/await} diff --git a/src/lib/RetrieveSHL.svelte b/src/lib/RetrieveSHL.svelte index a32bad44..e96c1f40 100644 --- a/src/lib/RetrieveSHL.svelte +++ b/src/lib/RetrieveSHL.svelte @@ -4,6 +4,7 @@ import type { SHLClient } from '$lib/managementClient'; import type { SHLRetrieveEvent } from './types'; import type { SOFClient } from '$lib/sofClient' + import { log } from '$lib/logger'; let shlClient: SHLClient = getContext('shlClient'); let sofClient: SOFClient = getContext('sofClient'); @@ -28,6 +29,17 @@ } catch (error) { fetchError = "Unable to retrieve most recent sharing link."; console.error(`Error retrieving SHL for patient ${patientId}: ${error}`); + log({ + severity: "error", + action: "read", + entity: { + detail: { + action: `Retrieve SHL for patient ${patientId}`, + message: fetchError + } + }, + outcome: `${JSON.stringify(error)}` + }) } // Meanwhile, in FetchSOF: // retrieve DocRefs for current patient (reports and shl metadata) @@ -43,6 +55,6 @@ {#if fetchError}

{fetchError}

- You can try again later, click "Back" to choose another option, or reach out for help below. + You can reach out for help below, or click "Back" for more options.
{/if} diff --git a/src/lib/config.ts b/src/lib/config.ts index 31808af3..e7a76f8c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,6 +2,11 @@ import { dev } from '$app/environment'; import { toMilliseconds } from '$lib/util'; +export const VERSION_STRING = import.meta.env.VITE_VERSION_STRING; + +export const LOG_URL = import.meta.env.VITE_LOG_URL; +export const SYSTEM_URL = import.meta.env.VITE_SYSTEM_URL; + export const API_BASE = import.meta.env.VITE_API_BASE; export const INTERMEDIATE_FHIR_SERVER_BASE = import.meta.env.VITE_INTERMEDIATE_FHIR_SERVER_BASE; diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 00000000..906aab59 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,115 @@ +import { LOG_URL, VERSION_STRING, SYSTEM_URL } from "./config"; + +type Action = 'create' | 'read' | 'update' | 'delete' | 'execute' | 'login' | 'logout'; +type Severity = 'critical' | 'error' | 'warning' | 'info' | 'debug'; + +export interface LogMessage { + version: string; // default: 2 + severity: Severity; // default: info + action: Action; + occurred?: string; // datetime of event + subject?: string; // subject id + agent?: { + ip_address?: string; // Handled by server + user_agent?: string; // Handled by server + type?: string; // Server default: user + who?: string; // user id + }; + source?: { + observer?: string; // system url + type?: string; // system name + version?: string; // system version + }; + entity?: { + detail?: {[key: string] : string}; // additional info e.g. {action: "Copied SHL url to clipboard"} + query?: string; // query info + }; + outcome?: string; // failure or warning details +} + +export interface LogMessageSimple extends Partial { + action: Action; +} +export function log(content: LogMessageSimple, dest?: string) { + Logger.Instance.log(content, dest); +} + +export class Logger { + private static _instance: Logger; + user_id: string; + session_id: string; + dest: string; + + private constructor(user_id: string = "unknown", session_id: string = "unknown") { + this.dest = LOG_URL || ""; + this.user_id = user_id; + this.session_id = session_id; + } + + public static get Instance(): Logger { + return this._instance || (this._instance = new this()); + } + + public setUserId(user_id: string): void { + this.user_id = user_id; + } + + public setSessionId(session_id: string): void { + this.session_id = session_id; + } + + public applyLogFallbacks(logMessage: LogMessageSimple, defaults: Partial): LogMessageSimple { + if (logMessage.entity) { + logMessage.entity.detail = {...(defaults.entity?.detail ?? {}), ...(logMessage.entity?.detail ?? {})}; + } + logMessage.entity = {...defaults.entity, ...logMessage.entity}; + logMessage.source = {...defaults.source, ...logMessage.source}; + logMessage.agent = {...defaults.agent, ...logMessage.agent}; + return {...defaults, ...logMessage}; + } + + public log(content: LogMessageSimple, dest?: string): void { + let logURL = dest ?? this.dest; + if (!logURL) { + console.log(JSON.stringify(content)); + return; + } + if (!URL.canParse(logURL)) { + console.warn('Invalid log destination: ' + logURL); + console.log(JSON.stringify(content)); + return; + } + + let defaults: Partial = { + version: "2", + severity: "info", + occurred: new Date().toISOString(), + subject: this.user_id, + agent: { + who: this.user_id, + }, + source: { + observer: SYSTEM_URL || window.location.origin, + type: 'shl-ltt', + version: VERSION_STRING, + }, + entity: { + detail: { + url: window.location.href, // current url + session_id: this.session_id + }, // additional info + }, + outcome: "", // failure or warning details + } + + let logMessage: LogMessageSimple = this.applyLogFallbacks(content, defaults); + + fetch(logURL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(logMessage), + }); + } +} \ No newline at end of file diff --git a/src/lib/managementClient.ts b/src/lib/managementClient.ts index 7d8b0de2..cd939d3c 100644 --- a/src/lib/managementClient.ts +++ b/src/lib/managementClient.ts @@ -56,7 +56,7 @@ export class SHLClient { }; } - async getUserShl(pid: string): Promise{ + async getUserShl(pid: string): Promise{ try { const req = await fetch(`${API_BASE}/user/${pid}`, { method: 'GET' diff --git a/src/lib/sofClient.js b/src/lib/sofClient.js index cd1980dc..fba908bf 100644 --- a/src/lib/sofClient.js +++ b/src/lib/sofClient.js @@ -1,5 +1,6 @@ import FHIR from 'fhirclient'; import { SOF_PATIENT_RESOURCES, SOF_RESOURCES, LOGOUT_URL, POST_LOGOUT_REDIRECT_URI } from './config.ts'; +import { Logger, log } from './logger'; const patientResourceScope = SOF_PATIENT_RESOURCES.map(resourceType => `patient/${resourceType}.read`); const resourceScope = patientResourceScope.join(" "); @@ -18,7 +19,26 @@ export class SOFClient { // Initialize FHIR client this.client = await FHIR.oauth2.init(this.configuration); this.patientId = this.getKeyCloakUserID(); + Logger.Instance.setUserId(this.patientId); + log({ + action: "login", + entity: { + detail: { + action: `Initialized FHIR client for user '${this.patientId}'` + } + } + }); } catch (error) { + log({ + severity: "error", + action: "login", + entity: { + detail: { + action: `Initialize FHIR client for user '${this.patientId}'` + } + }, + outcome: `${JSON.stringify(error)}` + }); console.error('Error initializing FHIR client:', error); } } @@ -42,7 +62,26 @@ export class SOFClient { this.reset(); if (logout_url !== "") { window.location.href = logout_url; + log({ + action: "logout", + entity: { + detail: { + action: `Logged out user '${this.patientId}'` + } + } + }); + Logger.Instance.setUserId(""); } else { + log({ + severity: "error", + action: "logout", + entity: { + detail: { + action: `Logout user '${this.patientId}'` + } + }, + outcome: `${JSON.stringify(error)}` + }); throw Error("Empty logout URL"); } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2842d61f..6a489065 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,4 +1,5 @@ - Let's Talk Tech - Share + Let's Talk Tech - Share { updateReady(detail) } } + on:shl-ready={({ detail }) => { + updateReady(detail); + }} /> {#if shlReady} - + {/if}