From 0a56d369c9a88b4b7b409c9402996ca4639c8c27 Mon Sep 17 00:00:00 2001 From: Rodion Chernyshov Date: Mon, 24 Nov 2025 11:29:58 +0100 Subject: [PATCH 1/6] fix: Implement FE/BE crash reports & metrix --- package-lock.json | 98 +++ poetry.lock | 66 +- pyproject.toml | 1 + src/ui/.env | 5 + src/ui/package.json | 3 +- src/ui/src/composables/useLogger.ts | 43 +- src/ui/src/composables/useWriterTracking.ts | 26 + src/ui/src/core/index.ts | 140 +++- src/ui/src/core/navigation.ts | 24 + src/ui/src/main.ts | 41 +- src/ui/src/observability/base.ts | 161 +++++ src/ui/src/observability/frontendMetrics.ts | 159 +++++ src/ui/src/observability/index.ts | 15 + src/ui/src/observability/sentryAdapter.ts | 666 ++++++++++++++++++++ src/ui/vite.config.ts | 17 +- src/writer/observability/__init__.py | 32 + src/writer/observability/base.py | 99 +++ src/writer/observability/sentry_adapter.py | 178 ++++++ src/writer/serve.py | 8 + 19 files changed, 1768 insertions(+), 14 deletions(-) create mode 100644 src/ui/.env create mode 100644 src/ui/src/observability/base.ts create mode 100644 src/ui/src/observability/frontendMetrics.ts create mode 100644 src/ui/src/observability/index.ts create mode 100644 src/ui/src/observability/sentryAdapter.ts create mode 100644 src/writer/observability/__init__.py create mode 100644 src/writer/observability/base.py create mode 100644 src/writer/observability/sentry_adapter.py diff --git a/package-lock.json b/package-lock.json index 23c5961d1..619b468e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1449,6 +1449,103 @@ "win32" ] }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.26.0.tgz", + "integrity": "sha512-rPg1+JZlfp912pZONQAWZzbSaZ9L6R2VrMcCEa+2e2Gqk9um4b+LqF5RQWZsbt5Z0n0azSy/KQ6zAe/zTPXSOg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.26.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.26.0.tgz", + "integrity": "sha512-0vk9eQP0CXD7Y2WkcCIWHaAqnXOAi18/GupgWLnbB2kuQVYVtStWxtW+OWRe8W/XwSnZ5m6JBTVeokuk/O16DQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.26.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.26.0.tgz", + "integrity": "sha512-FMySQnY2/p0dVtFUBgUO+aMdK2ovqnd7Q/AkvMQUsN/5ulyj6KZx3JX3CqOqRtAr1izoCe4Kh2pi5t//sQmvsg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.26.0", + "@sentry/core": "10.26.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.26.0.tgz", + "integrity": "sha512-vs7d/P+8M1L1JVAhhJx2wo15QDhqAipnEQvuRZ6PV7LUcS1un9/Vx49FMxpIkx6JcKADJVwtXrS1sX2hoNT/kw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.26.0", + "@sentry/core": "10.26.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.26.0.tgz", + "integrity": "sha512-uvV4hnkt8bh8yP0disJ0fszy8FdnkyGtzyIVKdeQZbNUefwbDhd3H0KJrAHhJ5ocULMH3B+dipdPmw2QXbEflg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.26.0", + "@sentry-internal/feedback": "10.26.0", + "@sentry-internal/replay": "10.26.0", + "@sentry-internal/replay-canvas": "10.26.0", + "@sentry/core": "10.26.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.26.0.tgz", + "integrity": "sha512-TjDe5QI37SLuV0q3nMOH8JcPZhv2e85FALaQMIhRILH9Ce6G7xW5GSjmH91NUVq8yc3XtiqYlz/EenEZActc4Q==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/vue": { + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.26.0.tgz", + "integrity": "sha512-KfgELqzuFc8beeYbx6u3Ed5l8Lj/iG0h8AgQ7YjK3FANsuwwFRioycwnoEMIJbEuKC9V3iRSHXk2W5Dgt1WWqw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "pinia": "2.x || 3.x", + "vue": "2.x || 3.x" + }, + "peerDependenciesMeta": { + "pinia": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.7", "license": "Apache-2.0", @@ -8449,6 +8546,7 @@ "@fullstory/browser": "^2.0.6", "@googlemaps/js-api-loader": "^1.16.6", "@monaco-editor/loader": "^1.3.3", + "@sentry/vue": "^10.25.0", "@tato30/vue-pdf": "^1.11.3", "@tiptap/extension-document": "^3.0.7", "@tiptap/extension-hard-break": "^3.0.7", diff --git a/poetry.lock b/poetry.lock index 6b19149bb..6fcbcbbe3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1785,6 +1785,70 @@ files = [ {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, ] +[[package]] +name = "sentry-sdk" +version = "2.45.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "sentry_sdk-2.45.0-py2.py3-none-any.whl", hash = "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23"}, + {file = "sentry_sdk-2.45.0.tar.gz", hash = "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271"}, +] + +[package.dependencies] +certifi = "*" +fastapi = {version = ">=0.79.0", optional = true, markers = "extra == \"fastapi\""} +urllib3 = ">=1.26.11" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +anthropic = ["anthropic (>=0.16)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +google-genai = ["google-genai (>=1.29.0)"] +grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] +http2 = ["httpcore[http2] (==1.*)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +huggingface-hub = ["huggingface_hub (>=0.22)"] +langchain = ["langchain (>=0.0.210)"] +langgraph = ["langgraph (>=0.6.6)"] +launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] +litellm = ["litellm (>=1.77.5)"] +litestar = ["litestar (>=2.0.0)"] +loguru = ["loguru (>=0.5)"] +mcp = ["mcp (>=1.15.0)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +openfeature = ["openfeature-sdk (>=0.7.1)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro"] +opentelemetry-otlp = ["opentelemetry-distro[otlp] (>=0.35b0)"] +pure-eval = ["asttokens", "executing", "pure_eval"] +pydantic-ai = ["pydantic-ai (>=1.0.0)"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +statsig = ["statsig (>=0.55.3)"] +tornado = ["tornado (>=6)"] +unleash = ["UnleashClient (>=6.0.1)"] + [[package]] name = "shellingham" version = "1.5.4" @@ -2188,4 +2252,4 @@ redis = [] [metadata] lock-version = "2.1" python-versions = ">=3.9.2, <4.0" -content-hash = "7006d3529d7d1638f9cf09ab98ea780ab416c49342e30d640f9218e45530e948" +content-hash = "0c1570f55dec7a309f880c83c931e62c56f4809c054609982bf69a0351f86070" diff --git a/pyproject.toml b/pyproject.toml index b037f6cda..7d411966a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ websockets = ">= 12, < 16" writer-sdk = ">= 2.3.1, < 3" python-multipart = ">=0.0.7, < 1" orjson = "^3.11.0, <4" +sentry-sdk = {extras = ["fastapi"], version = "^2.15.0"} [tool.poetry.group.build] optional = true diff --git a/src/ui/.env b/src/ui/.env new file mode 100644 index 000000000..aed0df12f --- /dev/null +++ b/src/ui/.env @@ -0,0 +1,5 @@ +VITE_SENTRY_DSN=https://6c13c3260114c5b1ceb3b6ae58d0cdb3@o1026471.ingest.us.sentry.io/4510398008393728 +VITE_SENTRY_ENABLED=true +VITE_SENTRY_ENVIRONMENT=production +VITE_SENTRY_TRACES_SAMPLE_RATE=1.0 +VITE_SENTRY_REPLAY_SAMPLE_RATE=0.1 diff --git a/src/ui/package.json b/src/ui/package.json index 8de87c006..c41911948 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -47,7 +47,8 @@ "vega-embed": "^6.22.1", "vega-lite": "^5.7.1", "vue": "^3.5.0", - "vue-dompurify-html": "^5.0.1" + "vue-dompurify-html": "^5.0.1", + "@sentry/vue": "^10.25.0" }, "devDependencies": { "@types/google.maps": "3.55.5", diff --git a/src/ui/src/composables/useLogger.ts b/src/ui/src/composables/useLogger.ts index 5eaed107a..0f08fa613 100644 --- a/src/ui/src/composables/useLogger.ts +++ b/src/ui/src/composables/useLogger.ts @@ -1,15 +1,44 @@ /* eslint-disable no-console */ +import { observabilityRegistry } from "@/observability"; + export type ILogger = Pick; -/** - * A simple abstraction to use logger in the application. For the moment, it's just a proxy to `console`, but it can be plugged to any library later. - */ export function useLogger(): ILogger { + const provider = observabilityRegistry.getInitializedProvider(); + return { - log: console.log, - warn: console.warn, - info: console.info, - error: console.error, + log: (...args: any[]) => { + console.log(...args); + }, + warn: (...args: any[]) => { + console.warn(...args); + if (provider && args.length > 0) { + const message = + typeof args[0] === "string" ? args[0] : String(args[0]); + provider.captureMessage(message, "warning", { + source: "logger", + component: "useLogger", + args: args.slice(1), + }); + } + }, + info: (...args: any[]) => { + console.info(...args); + }, + error: (...args: any[]) => { + console.error(...args); + if (provider && args.length > 0) { + const error = + args[0] instanceof Error + ? args[0] + : new Error(String(args[0])); + provider.captureException(error, { + source: "logger", + component: "useLogger", + args: args.slice(1), + }); + } + }, }; } diff --git a/src/ui/src/composables/useWriterTracking.ts b/src/ui/src/composables/useWriterTracking.ts index 2634d95ec..b4880325b 100644 --- a/src/ui/src/composables/useWriterTracking.ts +++ b/src/ui/src/composables/useWriterTracking.ts @@ -2,6 +2,7 @@ import type { generateCore } from "@/core"; import { useWriterApi } from "./useWriterApi"; import { watch } from "vue"; import { useLogger } from "./useLogger"; +import { observabilityRegistry } from "@/observability"; let isIdentified = false; @@ -185,6 +186,31 @@ export function useWriterTracking(wf: ReturnType) { expandEventPropertiesWithResources(properties); logger.log("[tracking]", eventNameFormated, propertiesExpanded); + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + ( + provider as { + incrementMetric: ( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ) => void; + } + ).incrementMetric(`user_action.${eventName}`, { + tags: { + event_type: eventName, + }, + unit: "none", + }); + } catch (e) { + logger.warn("Failed to track metric:", e); + } + } + return await Promise.all([ trackWithApi(eventNameFormated, propertiesExpanded), trackWithFullStory(eventNameFormated, propertiesExpanded), diff --git a/src/ui/src/core/index.ts b/src/ui/src/core/index.ts index 66f942133..e32786828 100644 --- a/src/ui/src/core/index.ts +++ b/src/ui/src/core/index.ts @@ -33,6 +33,11 @@ import { bigIntReplacer } from "./serializer"; import { useLogger } from "@/composables/useLogger"; import { readBlobAsArrayBufferJson } from "@/utils/blob"; import { RECONNECT_DELAY_MS } from "@/constants/retry"; +import { observabilityRegistry } from "@/observability"; +import { + trackWebSocketLatency, + trackInteractionDuration, +} from "@/observability/frontendMetrics"; import { createFileToSourceFiles, deleteFileToSourceFiles, @@ -185,7 +190,14 @@ export function generateCore() { function sendKeepAliveMessage() { setTimeout(() => { - sendFrontendMessage("keepAlive", {}, sendKeepAliveMessage); + const pingStartTime = performance.now(); + sendFrontendMessage("keepAlive", {}, (response) => { + if (response?.ok) { + const latency = performance.now() - pingStartTime; + trackWebSocketLatency(latency); + } + sendKeepAliveMessage(); + }); }, KEEP_ALIVE_DELAY_MS); } @@ -731,11 +743,25 @@ export function generateCore() { } const logger = useLogger(); const trackingId = frontendMessageCounter++; + const messageStartTime = performance.now(); + const interactionStartTime = performance.now(); + + const wrappedCallback = callback + ? (response: { ok: boolean; payload?: unknown }) => { + if (response?.ok && type !== "keepAlive") { + const duration = + performance.now() - interactionStartTime; + trackInteractionDuration(type, duration); + } + callback(response); + } + : undefined; + try { - if (callback || track) { + if (wrappedCallback || track) { frontendMessageMap.value.set(trackingId, { type, - callback, + callback: wrappedCallback, }); } if (track) { @@ -758,18 +784,126 @@ export function generateCore() { throw "Connection lost."; } webSocket.send(JSON.stringify(wsData, bigIntReplacer)); + + const messageDuration = performance.now() - messageStartTime; + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "recordDistribution" in provider) { + try { + ( + provider as { + recordDistribution: ( + name: string, + value: number, + options?: { + tags?: Record; + unit?: string; + }, + ) => void; + } + ).recordDistribution( + "websocket.message_duration", + messageDuration, + { + tags: { + message_type: type, + }, + unit: "millisecond", + }, + ); + } catch (_e) { + // Ignore metric errors + } + } } catch (error) { logger.error("sendFrontendMessage error", error); callback?.({ ok: false }); + + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + ( + provider as { + incrementMetric: ( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ) => void; + } + ).incrementMetric("websocket.message_error", { + tags: { + message_type: type, + }, + unit: "none", + }); + } catch (_e) { + // Ignore metric errors + } + } } } function deleteComponent(componentId: Component["id"]) { + const component = components.value[componentId]; delete components.value[componentId]; + + if (component) { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + ( + provider as { + incrementMetric: ( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ) => void; + } + ).incrementMetric("component.deleted", { + tags: { + component_type: component.type, + }, + unit: "none", + }); + } catch (_e) { + // Ignore metric errors + } + } + } } function addComponent(component: Component) { components.value[component.id] = component; + + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + ( + provider as { + incrementMetric: ( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ) => void; + } + ).incrementMetric("component.added", { + tags: { + component_type: component.type, + }, + unit: "none", + }); + } catch (_e) { + // Ignore metric errors + } + } } /** diff --git a/src/ui/src/core/navigation.ts b/src/ui/src/core/navigation.ts index b5177b668..3fd195413 100644 --- a/src/ui/src/core/navigation.ts +++ b/src/ui/src/core/navigation.ts @@ -1,3 +1,5 @@ +import { trackRouteChange as trackRouteChangeMetric } from "@/observability/frontendMetrics"; + export type ParsedHash = { pageKey?: string; routeVars: Map; // Stored as Map to avoid injection e.g. prototype pollution @@ -74,6 +76,7 @@ export function changePageInHash(targetPageKey: string) { const parsedHash = getParsedHash(); parsedHash.pageKey = targetPageKey; setHash(parsedHash); + trackRouteChange(); } export function changeRouteVarsInHash(targetRouteVars: Record) { @@ -83,4 +86,25 @@ export function changeRouteVarsInHash(targetRouteVars: Record) { Object.entries({ ...routeVars, ...targetRouteVars }), ); setHash(parsedHash); + trackRouteChange(); +} + +function trackRouteChange(): void { + if (typeof window === "undefined") { + return; + } + + try { + const parsedHash = getParsedHash(); + const route = parsedHash.pageKey || "root"; + trackRouteChangeMetric(route); + } catch (_e) { + // Ignore if metrics not available + } +} + +if (typeof window !== "undefined") { + window.addEventListener("hashchange", () => { + trackRouteChange(); + }); } diff --git a/src/ui/src/main.ts b/src/ui/src/main.ts index cd368bed2..cd91e9e46 100644 --- a/src/ui/src/main.ts +++ b/src/ui/src/main.ts @@ -12,6 +12,8 @@ import { useNotesManager } from "./core/useNotesManager.js"; import { CollaborationManager } from "./writerTypes.js"; import { useSecretsManager } from "./core/useSecretsManager.js"; import { RECONNECT_DELAY_MS, MAX_RETRIES } from "@/constants/retry"; +import { observabilityRegistry } from "./observability"; +import { trackPageLoadTime, trackError } from "./observability/frontendMetrics"; const wf = generateCore(); @@ -59,8 +61,36 @@ async function load() { app.provide(injectionKeys.collaborationManager, collaborationManager); app.provide(injectionKeys.secretsManager, secretsManager); + try { + await observabilityRegistry.initializeProvider(null, app); + } catch (error) { + logger.warn("Failed to initialize observability provider:", error); + } + app.mount("#app"); + trackPageLoadTime(); + + if (typeof window !== "undefined") { + window.addEventListener("error", (event) => { + const error = + event.error || new Error(event.message || "Unknown error"); + trackError(error, error.name || "window_error"); + }); + + window.addEventListener("unhandledrejection", (event) => { + const error = + event.reason instanceof Error + ? event.reason + : new Error( + String( + event.reason || "Unhandled promise rejection", + ), + ); + trackError(error, "unhandled_promise_rejection"); + }); + } + if (wf.isWriterCloudApp.value && collaborationManager) { await enableCollaboration(collaborationManager).catch(logger.error); } @@ -73,7 +103,7 @@ async function enableCollaboration(collaborationManager: CollaborationManager) { const { writerApi } = useWriterApi(); const writerProfile = await writerApi.fetchUserProfile(); collaborationManager.updateOutgoingPing({ - userId: writerProfile.id.toString(), + userId: writerProfile.id, action: "join", }); collaborationManager.sendCollaborationPing(); @@ -131,6 +161,15 @@ initialise() .catch((reason) => { logger.error("Core initialisation failed.", reason); + observabilityRegistry.captureException( + reason instanceof Error ? reason : new Error(String(reason)), + { + source: "core_initialization", + component: "main", + stage: "initialization", + }, + ); + const errorDiv = document.createElement("div"); errorDiv.className = "error-message"; errorDiv.setAttribute("role", "alert"); diff --git a/src/ui/src/observability/base.ts b/src/ui/src/observability/base.ts new file mode 100644 index 000000000..7ac603fbe --- /dev/null +++ b/src/ui/src/observability/base.ts @@ -0,0 +1,161 @@ +export interface ObservabilityProvider { + initialize(app?: unknown, router?: unknown): boolean | Promise; + captureException( + error: Error | string, + context?: Record, + ): void; + captureMessage( + message: string, + level?: "info" | "warning" | "error", + context?: Record, + ): void; + setUser(user: { + id?: string; + email?: string; + username?: string; + [key: string]: unknown; + }): void; + setContext(key: string, value: unknown): void; + isEnabled(): boolean; + getName(): string; +} + +export class ObservabilityRegistry { + private providers: Map = new Map(); + private initializedProvider: ObservabilityProvider | null = null; + + register(name: string, provider: ObservabilityProvider): void { + if (this.providers.has(name)) { + // eslint-disable-next-line no-console + console.warn(`Overwriting existing provider '${name}'`); + } + + this.providers.set(name, provider); + } + + getProvider(name: string): ObservabilityProvider | null { + return this.providers.get(name) || null; + } + + listProviders(): string[] { + return Array.from(this.providers.keys()); + } + + initializeProvider( + name?: string | null, + app?: unknown, + router?: unknown, + ): boolean | Promise { + if (name === null || name === undefined) { + const envProvider = import.meta.env.VITE_OBSERVABILITY_PROVIDER; + if (envProvider) { + name = envProvider; + } else { + for (const [ + providerName, + provider, + ] of this.providers.entries()) { + if (provider.isEnabled()) { + name = providerName; + break; + } + } + } + } + + if (!name) { + // eslint-disable-next-line no-console + console.info("No observability provider configured"); + return false; + } + + const provider = this.getProvider(name); + if (!provider) { + // eslint-disable-next-line no-console + console.warn( + `Observability provider '${name}' not found. Available: ${this.listProviders().join(", ")}`, + ); + return false; + } + + if (!provider.isEnabled()) { + // eslint-disable-next-line no-console + console.info(`Observability provider '${name}' is disabled`); + return false; + } + + try { + const result = provider.initialize(app, router); + if (result instanceof Promise) { + return result + .then((success) => { + if (success) { + this.initializedProvider = provider; + // eslint-disable-next-line no-console + console.info( + `Initialized observability provider: ${name}`, + ); + return true; + } else { + // eslint-disable-next-line no-console + console.warn( + `Failed to initialize observability provider: ${name}`, + ); + return false; + } + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error( + `Error initializing observability provider '${name}':`, + error, + ); + return false; + }); + } else { + if (result) { + this.initializedProvider = provider; + // eslint-disable-next-line no-console + console.info(`Initialized observability provider: ${name}`); + return true; + } else { + // eslint-disable-next-line no-console + console.warn( + `Failed to initialize observability provider: ${name}`, + ); + return false; + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Error initializing observability provider '${name}':`, + error, + ); + return false; + } + } + + getInitializedProvider(): ObservabilityProvider | null { + return this.initializedProvider; + } + + captureException( + error: Error | string, + context?: Record, + ): void { + if (this.initializedProvider) { + this.initializedProvider.captureException(error, context); + } + } + + captureMessage( + message: string, + level?: "info" | "warning" | "error", + context?: Record, + ): void { + if (this.initializedProvider) { + this.initializedProvider.captureMessage(message, level, context); + } + } +} diff --git a/src/ui/src/observability/frontendMetrics.ts b/src/ui/src/observability/frontendMetrics.ts new file mode 100644 index 000000000..ecc1444da --- /dev/null +++ b/src/ui/src/observability/frontendMetrics.ts @@ -0,0 +1,159 @@ +import { observabilityRegistry } from "./index"; + +export function trackPageLoadTime(): void { + if (typeof window === "undefined" || !window.performance) { + return; + } + + try { + const timing = window.performance.timing; + if (timing && timing.loadEventEnd && timing.navigationStart) { + const loadTime = timing.loadEventEnd - timing.navigationStart; + + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "recordDistribution" in provider) { + ( + provider as { + recordDistribution: ( + name: string, + value: number, + options?: { + tags?: Record; + unit?: string; + }, + ) => void; + } + ).recordDistribution("frontend_page_load_time_ms", loadTime, { + tags: { + metric_type: "performance", + }, + unit: "millisecond", + }); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to track page load time:", e); + } +} + +export function trackError(error: Error, errorType?: string): void { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + ( + provider as { + incrementMetric: ( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ) => void; + } + ).incrementMetric("frontend_errors_total", { + tags: { + error_type: errorType || error.name || "unknown", + error_message: + error.message?.substring(0, 100) || "unknown", + }, + unit: "none", + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to track error metric:", e); + } + } +} + +export function trackWebSocketLatency(latencyMs: number): void { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "recordDistribution" in provider) { + try { + ( + provider as { + recordDistribution: ( + name: string, + value: number, + options?: { + tags?: Record; + unit?: string; + }, + ) => void; + } + ).recordDistribution("websocket_latency_ms", latencyMs, { + tags: { + metric_type: "network", + }, + unit: "millisecond", + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to track websocket latency:", e); + } + } +} + +export function trackRouteChange(route: string): void { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + ( + provider as { + incrementMetric: ( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ) => void; + } + ).incrementMetric("frontend_route_changes_total", { + tags: { + route: route || "unknown", + }, + unit: "none", + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to track route change:", e); + } + } +} + +export function trackInteractionDuration( + actionType: string, + durationMs: number, +): void { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "recordDistribution" in provider) { + try { + ( + provider as { + recordDistribution: ( + name: string, + value: number, + options?: { + tags?: Record; + unit?: string; + }, + ) => void; + } + ).recordDistribution( + "frontend_interaction_duration_ms", + durationMs, + { + tags: { + action_type: actionType, + }, + unit: "millisecond", + }, + ); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to track interaction duration:", e); + } + } +} diff --git a/src/ui/src/observability/index.ts b/src/ui/src/observability/index.ts new file mode 100644 index 000000000..9f9171c28 --- /dev/null +++ b/src/ui/src/observability/index.ts @@ -0,0 +1,15 @@ +import { ObservabilityRegistry } from "./base"; +import { SentryAdapter } from "./sentryAdapter"; + +export type { ObservabilityProvider } from "./base"; +export { ObservabilityRegistry } from "./base"; +export { SentryAdapter } from "./sentryAdapter"; + +export const observabilityRegistry = new ObservabilityRegistry(); + +try { + observabilityRegistry.register("sentry", new SentryAdapter()); +} catch (error) { + // eslint-disable-next-line no-console + console.debug("Sentry adapter not available:", error); +} diff --git a/src/ui/src/observability/sentryAdapter.ts b/src/ui/src/observability/sentryAdapter.ts new file mode 100644 index 000000000..08131b322 --- /dev/null +++ b/src/ui/src/observability/sentryAdapter.ts @@ -0,0 +1,666 @@ +import type { ObservabilityProvider } from "./base"; + +export const SENTRY_DSN_ENV = "VITE_SENTRY_DSN"; +export const SENTRY_ENABLED_ENV = "VITE_SENTRY_ENABLED"; +export const SENTRY_ENVIRONMENT_ENV = "VITE_SENTRY_ENVIRONMENT"; +export const SENTRY_TRACES_SAMPLE_RATE_ENV = "VITE_SENTRY_TRACES_SAMPLE_RATE"; +export const SENTRY_REPLAY_SAMPLE_RATE_ENV = "VITE_SENTRY_REPLAY_SAMPLE_RATE"; + +interface SentryApi { + captureException: ( + error: Error, + options?: { + extra?: Record; + contexts?: Record; + }, + ) => void; + captureMessage: ( + message: string, + options?: { + level?: string; + extra?: Record; + contexts?: Record; + }, + ) => void; + setUser: (user: { + id?: string; + email?: string; + username?: string; + [key: string]: unknown; + }) => void; + setContext: (key: string, value: unknown) => void; +} + +interface SentryMetrics { + count: ( + name: string, + value?: number, + data?: { + attributes?: Record; + unit?: string; + }, + ) => void; + distribution: ( + name: string, + value: number, + data?: { + attributes?: Record; + unit?: string; + }, + ) => void; + gauge: ( + name: string, + value: number, + data?: { + attributes?: Record; + unit?: string; + }, + ) => void; +} + +interface SentryModule { + init: (config: SentryInitConfig) => void; + captureException: SentryApi["captureException"]; + captureMessage: SentryApi["captureMessage"]; + setUser: SentryApi["setUser"]; + setContext: SentryApi["setContext"]; + setTag?: (key: string, value: string) => void; + metrics?: SentryMetrics; + vueIntegration?: (options?: { app?: unknown }) => unknown; + browserTracingIntegration?: (options?: { + router?: unknown; + tracingOrigins?: (string | RegExp)[]; + routeLabel?: "name" | "path"; + }) => unknown; + BrowserTracing?: new (options?: { + router?: unknown; + tracingOrigins?: (string | RegExp)[]; + routeLabel?: "name" | "path"; + }) => unknown; +} + +interface SentryInitConfig { + dsn: string; + environment: string; + tracesSampleRate: number; + replay: { + sampleRate: number; + }; + enableMetrics?: boolean; + app?: unknown; + integrations?: unknown[]; +} + +type BrowserTracingFunction = (options?: { + router?: unknown; + tracingOrigins?: (string | RegExp)[]; + routeLabel?: "name" | "path"; +}) => unknown; +type BrowserTracingClass = new (options?: { + router?: unknown; + tracingOrigins?: (string | RegExp)[]; + routeLabel?: "name" | "path"; +}) => unknown; +type BrowserTracingIntegration = BrowserTracingFunction | BrowserTracingClass; + +export class SentryAdapter implements ObservabilityProvider { + private initialized = false; + private sentry: SentryApi | null = null; + private metrics: SentryMetrics | null = null; + private sentryModule: SentryModule | null = null; + private app: unknown = null; + private router: unknown = null; + + isEnabled(): boolean { + return import.meta.env[SENTRY_ENABLED_ENV] !== "false"; + } + + getName(): string { + return "sentry"; + } + + async initialize(app?: unknown, router?: unknown): Promise { + if (this.initialized) { + return true; + } + + if (router) { + this.router = router; + } + + try { + const dsn = import.meta.env[SENTRY_DSN_ENV]; + if (!dsn) { + // eslint-disable-next-line no-console + console.info( + "Sentry DSN not provided, skipping Sentry initialization", + ); + return false; + } + + let SentryModule: SentryModule; + try { + const sentryImport = await import( + /* @vite-ignore */ "@sentry/vue" + ); + SentryModule = sentryImport as unknown as SentryModule; + } catch (importError) { + // eslint-disable-next-line no-console + console.warn( + "Failed to import @sentry/vue package. Make sure it's installed: npm install @sentry/vue", + importError, + ); + return false; + } + + const { + init, + captureException, + captureMessage, + setUser, + setContext, + } = SentryModule; + + const integrations: unknown[] = []; + + if (SentryModule.vueIntegration) { + integrations.push( + SentryModule.vueIntegration({ + app: app || undefined, + }), + ); + } + + let browserTracingIntegration: BrowserTracingIntegration | null = + null; + if (SentryModule.browserTracingIntegration) { + browserTracingIntegration = + SentryModule.browserTracingIntegration; + } else if (SentryModule.BrowserTracing) { + browserTracingIntegration = SentryModule.BrowserTracing; + } else { + try { + const browserModule = (await import( + /* @vite-ignore */ "@sentry/browser" + )) as { + browserTracingIntegration?: BrowserTracingIntegration; + }; + if (browserModule.browserTracingIntegration) { + browserTracingIntegration = + browserModule.browserTracingIntegration; + } + } catch { + // eslint-disable-next-line no-console + console.warn( + "BrowserTracing not available. Performance monitoring will be limited.", + ); + } + } + + const environment = + import.meta.env[SENTRY_ENVIRONMENT_ENV] || + import.meta.env.MODE || + "production"; + const tracesSampleRate = parseFloat( + import.meta.env[SENTRY_TRACES_SAMPLE_RATE_ENV] || "1.0", + ); + const replaySampleRate = parseFloat( + import.meta.env[SENTRY_REPLAY_SAMPLE_RATE_ENV] || "0.1", + ); + + const initConfig: SentryInitConfig = { + dsn, + environment, + tracesSampleRate, + replay: { + sampleRate: replaySampleRate, + }, + enableMetrics: true, + }; + + if (app) { + initConfig.app = app; + this.app = app; + } + + if (browserTracingIntegration) { + const tracingOptions: { + router?: unknown; + tracingOrigins?: (string | RegExp)[]; + routeLabel?: "name" | "path"; + } = { + tracingOrigins: ["localhost", /^\//], + }; + + if (this.router) { + tracingOptions.router = this.router; + tracingOptions.routeLabel = "path"; + } + + if ( + typeof browserTracingIntegration === "function" && + !browserTracingIntegration.prototype + ) { + const integration = + browserTracingIntegration as BrowserTracingFunction; + integrations.push(integration(tracingOptions)); + } else { + const IntegrationClass = + browserTracingIntegration as BrowserTracingClass; + integrations.push(new IntegrationClass(tracingOptions)); + } + } + + if (integrations.length > 0) { + initConfig.integrations = integrations; + } + + init(initConfig); + + this.sentry = { + captureException, + captureMessage, + setUser, + setContext, + }; + + this.sentryModule = SentryModule; + this.metrics = this._getMetrics(SentryModule); + + if (this.metrics) { + // eslint-disable-next-line no-console + console.debug("Sentry metrics API initialized"); + } else { + // eslint-disable-next-line no-console + console.warn( + "Sentry metrics API not available (requires SDK 10.25.0+)", + ); + } + + this.initialized = true; + + this._setInitialMetadata(SentryModule); + this._setupRouteTracking(); + + // eslint-disable-next-line no-console + console.info(`Sentry initialized (environment: ${environment})`); + return true; + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + "Failed to load or initialize @sentry/vue package:", + error, + ); + return false; + } + } + + captureException( + error: Error | string, + context?: Record, + ): void { + if (!this.initialized || !this.sentry) { + return; + } + + try { + const runtimeContext = { + url: window.location.href, + userAgent: navigator.userAgent, + }; + + if (error instanceof Error) { + this.sentry.captureException(error, { + extra: { + ...context, + url: window.location.href, + timestamp: new Date().toISOString(), + }, + contexts: { + runtime: runtimeContext, + ...((context?.contexts as Record) || + {}), + }, + }); + } else { + this.sentry.captureMessage(error, { + level: "error", + extra: { + ...context, + url: window.location.href, + timestamp: new Date().toISOString(), + }, + contexts: { + runtime: runtimeContext, + ...((context?.contexts as Record) || + {}), + }, + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to capture exception:", e); + } + } + + captureMessage( + message: string, + level: "info" | "warning" | "error" = "info", + context?: Record, + ): void { + if (!this.initialized || !this.sentry) { + return; + } + + try { + this.sentry.captureMessage(message, { + level, + extra: { + ...context, + url: window.location.href, + timestamp: new Date().toISOString(), + }, + contexts: { + runtime: { + url: window.location.href, + userAgent: navigator.userAgent, + }, + ...((context?.contexts as Record) || {}), + }, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to capture message:", e); + } + } + + setUser(user: { + id?: string; + email?: string; + username?: string; + [key: string]: unknown; + }): void { + if (!this.initialized || !this.sentry) { + return; + } + + try { + const { setUser } = this.sentry; + setUser(user); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to set user in Sentry:", e); + } + } + + setContext(key: string, value: unknown): void { + if (!this.initialized || !this.sentry) { + return; + } + + try { + const { setContext } = this.sentry; + setContext(key, value); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to set context in Sentry:", e); + } + } + + private _setInitialMetadata(SentryModule: SentryModule): void { + if (!this.initialized || !SentryModule) { + return; + } + + try { + if (SentryModule.setTag) { + SentryModule.setTag("platform", "frontend"); + SentryModule.setTag("framework", "writer-framework"); + + const appMetadata = this._getAppMetadata(); + if (appMetadata.agent_id) { + SentryModule.setTag("agent_id", appMetadata.agent_id); + } + if (appMetadata.organization_id) { + SentryModule.setTag( + "organization_id", + appMetadata.organization_id, + ); + } + if (appMetadata.mode) { + SentryModule.setTag("mode", appMetadata.mode); + } + } + + if (SentryModule.setContext) { + const appMetadata = this._getAppMetadata(); + SentryModule.setContext("application", appMetadata); + SentryModule.setContext("runtime", { + name: "browser", + user_agent: navigator.userAgent, + language: navigator.language, + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to set initial Sentry metadata:", e); + } + } + + private _getMetrics(module: SentryModule): SentryMetrics | null { + // Try module first + if (module.metrics) { + return module.metrics as SentryMetrics; + } + + // Try global Sentry object (after init) + if (typeof window !== "undefined") { + const sentryGlobal = ( + window as { Sentry?: { metrics?: SentryMetrics } } + ).Sentry; + if (sentryGlobal?.metrics) { + return sentryGlobal.metrics; + } + } + + return null; + } + + private _setupRouteTracking(): void { + if (!this.initialized || !this.router) { + return; + } + + try { + const router = this.router as { + afterEach?: ( + callback: (to: { path: string; name?: string }) => void, + ) => void; + }; + + if (typeof router.afterEach === "function") { + router.afterEach((to) => { + const route = to.name || to.path || "unknown"; + this.incrementMetric("frontend_route_changes_total", { + tags: { route }, + unit: "none", + }); + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to set up Vue Router tracking:", e); + } + } + + private _getAppMetadata(): { + agent_id?: string; + organization_id?: string; + mode?: string; + } { + const metadata: { + agent_id?: string; + organization_id?: string; + mode?: string; + } = {}; + + try { + if ( + this.app && + typeof this.app === "object" && + "config" in this.app + ) { + const vueApp = this.app as { + config?: { + globalProperties?: { + $core?: { + writerAppId?: { value?: string }; + writerOrgId?: { value?: number | string }; + mode?: { value?: string }; + }; + }; + }; + }; + + const core = vueApp?.config?.globalProperties?.$core; + if (core) { + if (core.writerAppId?.value) { + metadata.agent_id = core.writerAppId.value; + } + if (core.writerOrgId?.value) { + metadata.organization_id = String( + core.writerOrgId.value, + ); + } + if (core.mode?.value) { + metadata.mode = core.mode.value; + } + } + } + + if (!metadata.agent_id && typeof window !== "undefined") { + const globalCore = ( + window as unknown as { + core?: { writerAppId?: { value?: string } }; + } + ).core; + if (globalCore?.writerAppId?.value) { + metadata.agent_id = globalCore.writerAppId.value; + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.debug("Failed to get app metadata:", e); + } + + return metadata; + } + + private _getMetricsInstance(): SentryMetrics | null { + if (this.metrics) { + return this.metrics; + } + if (this.sentryModule) { + return this._getMetrics(this.sentryModule); + } + return null; + } + + private _buildAttributes( + tags?: Record, + ): Record { + const metadata = this._getAppMetadata(); + return { + ...(tags || {}), + ...(metadata.agent_id && { agent_id: metadata.agent_id }), + ...(metadata.organization_id && { + organization_id: metadata.organization_id, + }), + ...(metadata.mode && { mode: metadata.mode }), + }; + } + + incrementMetric( + name: string, + options?: { + tags?: Record; + unit?: string; + value?: number; + }, + ): void { + if (!this.initialized) { + return; + } + + const metrics = this._getMetricsInstance(); + if (!metrics) { + return; + } + + try { + metrics.count(name, options?.value ?? 1, { + attributes: this._buildAttributes(options?.tags), + unit: options?.unit, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Failed to send metric ${name}:`, e); + } + } + + recordDistribution( + name: string, + value: number, + options?: { + tags?: Record; + unit?: string; + }, + ): void { + if (!this.initialized) { + return; + } + + const metrics = this._getMetricsInstance(); + if (!metrics) { + return; + } + + try { + metrics.distribution(name, value, { + attributes: this._buildAttributes(options?.tags), + unit: options?.unit, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Failed to send distribution ${name}:`, e); + } + } + + setGauge( + name: string, + value: number, + options?: { + tags?: Record; + unit?: string; + }, + ): void { + if (!this.initialized) { + return; + } + + const metrics = this._getMetricsInstance(); + if (!metrics) { + return; + } + + try { + metrics.gauge(name, value, { + attributes: this._buildAttributes(options?.tags), + unit: options?.unit, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Failed to send gauge ${name}:`, e); + } + } +} diff --git a/src/ui/vite.config.ts b/src/ui/vite.config.ts index 7707e4692..14e737267 100644 --- a/src/ui/vite.config.ts +++ b/src/ui/vite.config.ts @@ -11,7 +11,22 @@ export default defineConfig({ includeWriterComponentPath: false, define: { WRITER_LIVE_CCT: JSON.stringify("no"), - WRITER_FRAMEWORK_VERSION: JSON.stringify(process.env.WRITER_FRAMEWORK_VERSION || ""), + WRITER_FRAMEWORK_VERSION: JSON.stringify( + process.env.WRITER_FRAMEWORK_VERSION || "", + ), + VITE_SENTRY_DSN: JSON.stringify(process.env.VITE_SENTRY_DSN || ""), + VITE_SENTRY_ENABLED: JSON.stringify( + process.env.VITE_SENTRY_ENABLED || "false", + ), + VITE_SENTRY_ENVIRONMENT: JSON.stringify( + process.env.VITE_SENTRY_ENVIRONMENT || "", + ), + VITE_SENTRY_TRACES_SAMPLE_RATE: JSON.stringify( + process.env.VITE_SENTRY_TRACES_SAMPLE_RATE || "1.0", + ), + VITE_SENTRY_REPLAY_SAMPLE_RATE: JSON.stringify( + process.env.VITE_SENTRY_REPLAY_SAMPLE_RATE || "0.1", + ), }, css: { postcss: { diff --git a/src/writer/observability/__init__.py b/src/writer/observability/__init__.py new file mode 100644 index 000000000..791c02ea1 --- /dev/null +++ b/src/writer/observability/__init__.py @@ -0,0 +1,32 @@ +""" +Observability integration module with adapter pattern support. +""" +import logging +from typing import Optional + +from writer.observability.base import ObservabilityProvider, ObservabilityRegistry + +logger = logging.getLogger(__name__) + +__all__ = [ + "ObservabilityProvider", + "ObservabilityRegistry", +] + +observability_registry = ObservabilityRegistry() + + +def _register_sentry_adapter(app_path: Optional[str] = None): + try: + from writer.observability.sentry_adapter import SentryAdapter + sentry_adapter = SentryAdapter(app_path=app_path) + if sentry_adapter.is_enabled(): + observability_registry.register("sentry", sentry_adapter) + __all__.append("SentryAdapter") + except Exception as e: + logger.debug(f"Sentry adapter not available: {e}") + + +def get_registry(app_path: Optional[str] = None): + _register_sentry_adapter(app_path=app_path) + return observability_registry diff --git a/src/writer/observability/base.py b/src/writer/observability/base.py new file mode 100644 index 000000000..c490de493 --- /dev/null +++ b/src/writer/observability/base.py @@ -0,0 +1,99 @@ +import logging +import os +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +class ObservabilityProvider(ABC): + """Abstract base class for observability providers.""" + + @abstractmethod + def initialize(self) -> bool: + """Initialize the observability provider.""" + pass + + @abstractmethod + def instrument_fastapi_app(self, app: Any) -> None: + """Instrument FastAPI application with observability.""" + pass + + def get_name(self) -> str: + """Get the name of the provider.""" + return self.__class__.__name__.lower().replace("adapter", "").replace("provider", "") + + def is_enabled(self) -> bool: + """Check if the provider is enabled.""" + return True + + +class ObservabilityRegistry: + """Registry for managing observability providers.""" + + def __init__(self): + self._providers: Dict[str, ObservabilityProvider] = {} + self._initialized_provider: Optional[ObservabilityProvider] = None + + def register(self, name: str, provider: ObservabilityProvider) -> None: + """Register an observability provider.""" + if name in self._providers: + logger.warning(f"Overwriting existing provider '{name}'") + self._providers[name] = provider + + def get_provider(self, name: str) -> Optional[ObservabilityProvider]: + """Get a provider by name.""" + return self._providers.get(name) + + def list_providers(self) -> List[str]: + """List all registered provider names.""" + return list(self._providers.keys()) + + def initialize_provider(self, name: Optional[str] = None) -> bool: + """Initialize a provider.""" + if name is None: + name = os.getenv("OBSERVABILITY_PROVIDER") + if name is None: + for provider_name, provider in self._providers.items(): + if provider.is_enabled(): + name = provider_name + break + + if name is None: + logger.info("No observability provider configured") + return False + + provider = self.get_provider(name) + if provider is None: + logger.warning(f"Observability provider '{name}' not found. Available: {self.list_providers()}") + return False + + if not provider.is_enabled(): + logger.info(f"Observability provider '{name}' is disabled") + return False + + try: + if provider.initialize(): + self._initialized_provider = provider + logger.info(f"Initialized observability provider: {name}") + return True + else: + logger.warning(f"Failed to initialize observability provider: {name}") + return False + except Exception as e: + logger.error(f"Error initializing observability provider '{name}': {e}", exc_info=True) + return False + + def instrument_app(self, app: Any) -> None: + """Instrument the app with the initialized provider.""" + if self._initialized_provider is None: + logger.debug("No observability provider initialized, skipping instrumentation") + return + try: + self._initialized_provider.instrument_fastapi_app(app) + except Exception as e: + logger.error(f"Error instrumenting app with observability provider: {e}", exc_info=True) + + def get_initialized_provider(self) -> Optional[ObservabilityProvider]: + """Get the currently initialized provider.""" + return self._initialized_provider + diff --git a/src/writer/observability/sentry_adapter.py b/src/writer/observability/sentry_adapter.py new file mode 100644 index 000000000..66f727f4b --- /dev/null +++ b/src/writer/observability/sentry_adapter.py @@ -0,0 +1,178 @@ +import logging +import os +import sys +from typing import TYPE_CHECKING, Optional + +from writer.observability.base import ObservabilityProvider + +if TYPE_CHECKING: + from fastapi import FastAPI + +logger = logging.getLogger(__name__) + +SENTRY_ENABLED_ENV = "SENTRY_ENABLED" +SENTRY_DSN_ENV = "SENTRY_DSN" +SENTRY_ENVIRONMENT_ENV = "SENTRY_ENVIRONMENT" +SENTRY_TRACES_SAMPLE_RATE_ENV = "SENTRY_TRACES_SAMPLE_RATE" + + +class SentryAdapter(ObservabilityProvider): + """Sentry observability provider adapter.""" + + def __init__(self, app_path: Optional[str] = None): + self._initialized = False + self._app_path = app_path + self._agent_id: Optional[str] = None + self._org_id: Optional[str] = None + + def is_enabled(self) -> bool: + """Check if Sentry is enabled.""" + return os.getenv(SENTRY_ENABLED_ENV, "true").lower() != "false" + + def _get_metadata(self) -> dict: + """Get application metadata for Sentry tags/contexts.""" + metadata = { + "agent_id": self._agent_id or os.getenv("WRITER_APP_ID"), + "organization_id": self._org_id or os.getenv("WRITER_ORG_ID"), + } + if self._app_path: + metadata["app_path"] = self._app_path + metadata["app_name"] = os.path.basename(self._app_path) + return metadata + + def initialize(self) -> bool: + """Initialize Sentry SDK.""" + if self._initialized: + return True + + if not self.is_enabled(): + logger.debug("Sentry is disabled via environment variable") + return False + + try: + import sentry_sdk + from sentry_sdk.integrations.logging import LoggingIntegration + + sentry_dsn = os.getenv(SENTRY_DSN_ENV) + if not sentry_dsn: + logger.debug("Sentry DSN not provided, skipping initialization") + return False + + self._agent_id = os.getenv("WRITER_APP_ID") + self._org_id = os.getenv("WRITER_ORG_ID") + environment = os.getenv(SENTRY_ENVIRONMENT_ENV, "production") + traces_sample_rate = float(os.getenv(SENTRY_TRACES_SAMPLE_RATE_ENV, "1.0")) + traces_sample_rate = max(0.0, min(1.0, traces_sample_rate)) + + metadata = self._get_metadata() + + def before_send(event, hint): + """Filter sensitive data before sending to Sentry.""" + # Remove sensitive headers if present + if "request" in event.get("contexts", {}): + headers = event["contexts"]["request"].get("headers", {}) + sensitive_keys = ["authorization", "cookie", "x-api-key"] + for key in sensitive_keys: + headers.pop(key.lower(), None) + return event + + sentry_sdk.init( + dsn=sentry_dsn, + environment=environment, + traces_sample_rate=traces_sample_rate, + integrations=[LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)], + enable_tracing=True, + default_integrations=False, + before_send=before_send, + ) + + # Set global tags and contexts + with sentry_sdk.configure_scope() as scope: + scope.set_tag("platform", "backend") + scope.set_tag("framework", "writer-framework") + if metadata["agent_id"]: + scope.set_tag("agent_id", metadata["agent_id"]) + if metadata["organization_id"]: + scope.set_tag("organization_id", metadata["organization_id"]) + if metadata.get("app_path"): + scope.set_tag("app_path", metadata["app_path"]) + scope.set_context("application", { + "path": metadata["app_path"], + "name": metadata.get("app_name"), + }) + scope.set_context("runtime", { + "name": "python", + "version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + }) + + logger.info(f"Sentry initialized (environment: {environment})") + self._initialized = True + return True + + except ImportError: + logger.debug("Sentry SDK not available") + return False + except Exception as e: + logger.error(f"Failed to initialize Sentry: {e}", exc_info=True) + return False + + def instrument_fastapi_app(self, app: "FastAPI") -> None: + """Instrument FastAPI app with Sentry middleware.""" + if not self._initialized: + logger.debug("Sentry not initialized, skipping FastAPI instrumentation") + return + + try: + from fastapi import Request + from starlette.middleware.base import BaseHTTPMiddleware + import sentry_sdk + + metadata = self._get_metadata() + + class SentryMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + with sentry_sdk.push_scope() as scope: + # Set request-specific tags (override global if needed) + scope.set_tag("source", "fastapi_middleware") + scope.set_tag("http.method", request.method) + scope.set_tag("http.path", request.url.path) + + # Prefer header values over env vars for request context + agent_id = request.headers.get("x-agent-id") or metadata.get("agent_id") + org_id = request.headers.get("x-organization-id") or metadata.get("organization_id") + + if agent_id: + scope.set_tag("agent_id", agent_id) + if org_id: + scope.set_tag("organization_id", org_id) + if metadata.get("app_path"): + scope.set_tag("app_path", metadata["app_path"]) + + scope.set_context("request", { + "method": request.method, + "url": str(request.url), + "path": request.url.path, + "query_params": dict(request.query_params), + }) + + try: + response = await call_next(request) + scope.set_tag("http.status_code", response.status_code) + return response + except Exception as e: + scope.set_tag("error_type", type(e).__name__) + sentry_sdk.capture_exception(e) + raise + + app.add_middleware(SentryMiddleware) + logger.debug("FastAPI app instrumented with Sentry middleware") + except ImportError: + logger.debug("FastAPI/Starlette not available for middleware") + except RuntimeError as e: + if "Cannot add middleware" in str(e): + logger.warning("FastAPI app already started, middleware not added") + else: + logger.error(f"Failed to add Sentry middleware: {e}", exc_info=True) + except Exception as e: + logger.error(f"Failed to instrument FastAPI app: {e}", exc_info=True) + diff --git a/src/writer/serve.py b/src/writer/serve.py index ea0e36764..2c896ef65 100644 --- a/src/writer/serve.py +++ b/src/writer/serve.py @@ -164,6 +164,14 @@ async def lifespan(asgi_app: FastAPI): app.state.writer_app = True app.state.app_runner = app_runner + try: + from writer.observability import get_registry + observability_registry = get_registry(app_path=user_app_path) + if observability_registry.initialize_provider(): + observability_registry.instrument_app(app) + except Exception as e: + logging.warning(f"Failed to initialize observability provider: {e}") + def _get_extension_paths() -> List[str]: extensions_path = pathlib.Path(user_app_path) / "extensions" if not extensions_path.exists(): From cd45c2d9bcb32cada398c0dd59ed57769ef31141 Mon Sep 17 00:00:00 2001 From: Rodion Chernyshov Date: Mon, 24 Nov 2025 11:35:53 +0100 Subject: [PATCH 2/6] fix: Implement FE/BE crash reports & metrix --- src/writer/observability/sentry_adapter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/writer/observability/sentry_adapter.py b/src/writer/observability/sentry_adapter.py index 66f727f4b..7d0513966 100644 --- a/src/writer/observability/sentry_adapter.py +++ b/src/writer/observability/sentry_adapter.py @@ -132,7 +132,6 @@ def instrument_fastapi_app(self, app: "FastAPI") -> None: class SentryMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): with sentry_sdk.push_scope() as scope: - # Set request-specific tags (override global if needed) scope.set_tag("source", "fastapi_middleware") scope.set_tag("http.method", request.method) scope.set_tag("http.path", request.url.path) From c0016907be5d32a24a1563528cd380edf2c7a308 Mon Sep 17 00:00:00 2001 From: Rodion Chernyshov Date: Mon, 24 Nov 2025 23:34:27 +0100 Subject: [PATCH 3/6] fix: Implement FE/BE crash reports & metrix --- .../src/composables/useBlueprintNodeTools.ts | 1 + .../src/composables/useGlobalErrorHandling.ts | 70 +++++ src/ui/src/composables/useLogger.ts | 8 +- src/ui/src/composables/useWriterTracking.ts | 39 +-- src/ui/src/core/index.ts | 147 +++------- src/ui/src/main.ts | 25 +- src/ui/src/observability/base.ts | 45 ++- src/ui/src/observability/frontendMetrics.ts | 262 ++++++++++-------- src/ui/src/observability/sentryAdapter.ts | 227 +++++++-------- src/writer/observability/__init__.py | 6 +- src/writer/observability/sentry_adapter.py | 1 - 11 files changed, 407 insertions(+), 424 deletions(-) create mode 100644 src/ui/src/composables/useGlobalErrorHandling.ts diff --git a/src/ui/src/composables/useBlueprintNodeTools.ts b/src/ui/src/composables/useBlueprintNodeTools.ts index af68804b2..f665ffa54 100644 --- a/src/ui/src/composables/useBlueprintNodeTools.ts +++ b/src/ui/src/composables/useBlueprintNodeTools.ts @@ -71,3 +71,4 @@ export function useBlueprintNodeTools( hasToolsButNoFunctionTools, }; } + diff --git a/src/ui/src/composables/useGlobalErrorHandling.ts b/src/ui/src/composables/useGlobalErrorHandling.ts new file mode 100644 index 000000000..90ec3f98e --- /dev/null +++ b/src/ui/src/composables/useGlobalErrorHandling.ts @@ -0,0 +1,70 @@ +import { trackError } from "@/observability/frontendMetrics"; + +let isInitialized = false; +let errorHandlerRef: ((event: ErrorEvent) => void) | null = null; +let rejectionHandlerRef: ((event: PromiseRejectionEvent) => void) | null = null; + +function extractError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + if (typeof error === "string") { + return new Error(error); + } + return new Error(String(error || "Unknown error")); +} + +function handleError(event: ErrorEvent): void { + try { + const error = extractError(event.error || event.message); + trackError(error, error.name || "window_error"); + } catch (trackingError) { + console.error("Failed to track error:", trackingError); + } +} + +function handleUnhandledRejection(event: PromiseRejectionEvent): void { + try { + const error = extractError(event.reason); + trackError(error, "unhandled_promise_rejection"); + } catch (trackingError) { + console.error("Failed to track promise rejection:", trackingError); + } +} + +export function setupGlobalErrorHandling(): void { + if (typeof window === "undefined") { + return; + } + + if (isInitialized) { + console.warn("Global error handling already initialized"); + return; + } + + errorHandlerRef = handleError; + rejectionHandlerRef = handleUnhandledRejection; + + window.addEventListener("error", errorHandlerRef); + window.addEventListener("unhandledrejection", rejectionHandlerRef); + + isInitialized = true; +} + +export function teardownGlobalErrorHandling(): void { + if (typeof window === "undefined" || !isInitialized) { + return; + } + + if (errorHandlerRef) { + window.removeEventListener("error", errorHandlerRef); + errorHandlerRef = null; + } + + if (rejectionHandlerRef) { + window.removeEventListener("unhandledrejection", rejectionHandlerRef); + rejectionHandlerRef = null; + } + + isInitialized = false; +} diff --git a/src/ui/src/composables/useLogger.ts b/src/ui/src/composables/useLogger.ts index 0f08fa613..268ec0e1f 100644 --- a/src/ui/src/composables/useLogger.ts +++ b/src/ui/src/composables/useLogger.ts @@ -8,9 +8,8 @@ export function useLogger(): ILogger { const provider = observabilityRegistry.getInitializedProvider(); return { - log: (...args: any[]) => { - console.log(...args); - }, + log: console.log, + info: console.info, warn: (...args: any[]) => { console.warn(...args); if (provider && args.length > 0) { @@ -23,9 +22,6 @@ export function useLogger(): ILogger { }); } }, - info: (...args: any[]) => { - console.info(...args); - }, error: (...args: any[]) => { console.error(...args); if (provider && args.length > 0) { diff --git a/src/ui/src/composables/useWriterTracking.ts b/src/ui/src/composables/useWriterTracking.ts index b4880325b..7feec7341 100644 --- a/src/ui/src/composables/useWriterTracking.ts +++ b/src/ui/src/composables/useWriterTracking.ts @@ -2,7 +2,10 @@ import type { generateCore } from "@/core"; import { useWriterApi } from "./useWriterApi"; import { watch } from "vue"; import { useLogger } from "./useLogger"; -import { observabilityRegistry } from "@/observability"; +import { + MetricUnit, + incrementMetricSafely, +} from "@/observability/frontendMetrics"; let isIdentified = false; @@ -186,30 +189,16 @@ export function useWriterTracking(wf: ReturnType) { expandEventPropertiesWithResources(properties); logger.log("[tracking]", eventNameFormated, propertiesExpanded); - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "incrementMetric" in provider) { - try { - ( - provider as { - incrementMetric: ( - name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, - ) => void; - } - ).incrementMetric(`user_action.${eventName}`, { - tags: { - event_type: eventName, - }, - unit: "none", - }); - } catch (e) { - logger.warn("Failed to track metric:", e); - } - } + incrementMetricSafely( + `user_action.${eventName}`, + { + tags: { + event_type: eventName, + }, + unit: MetricUnit.None, + }, + logger, + ); return await Promise.all([ trackWithApi(eventNameFormated, propertiesExpanded), diff --git a/src/ui/src/core/index.ts b/src/ui/src/core/index.ts index e32786828..e32e890ed 100644 --- a/src/ui/src/core/index.ts +++ b/src/ui/src/core/index.ts @@ -33,10 +33,12 @@ import { bigIntReplacer } from "./serializer"; import { useLogger } from "@/composables/useLogger"; import { readBlobAsArrayBufferJson } from "@/utils/blob"; import { RECONNECT_DELAY_MS } from "@/constants/retry"; -import { observabilityRegistry } from "@/observability"; +import { MetricName, MetricUnit } from "@/observability/frontendMetrics"; import { trackWebSocketLatency, trackInteractionDuration, + incrementMetricSafely, + recordDistributionSafely, } from "@/observability/frontendMetrics"; import { createFileToSourceFiles, @@ -786,124 +788,67 @@ export function generateCore() { webSocket.send(JSON.stringify(wsData, bigIntReplacer)); const messageDuration = performance.now() - messageStartTime; - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "recordDistribution" in provider) { - try { - ( - provider as { - recordDistribution: ( - name: string, - value: number, - options?: { - tags?: Record; - unit?: string; - }, - ) => void; - } - ).recordDistribution( - "websocket.message_duration", - messageDuration, - { - tags: { - message_type: type, - }, - unit: "millisecond", - }, - ); - } catch (_e) { - // Ignore metric errors - } - } + recordDistributionSafely( + MetricName.WebSocketMessageDuration, + messageDuration, + { + tags: { + message_type: type, + }, + unit: MetricUnit.Millisecond, + }, + logger, + ); } catch (error) { logger.error("sendFrontendMessage error", error); callback?.({ ok: false }); - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "incrementMetric" in provider) { - try { - ( - provider as { - incrementMetric: ( - name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, - ) => void; - } - ).incrementMetric("websocket.message_error", { - tags: { - message_type: type, - }, - unit: "none", - }); - } catch (_e) { - // Ignore metric errors - } - } + incrementMetricSafely( + MetricName.WebSocketMessageError, + { + tags: { + message_type: type, + }, + unit: MetricUnit.None, + }, + logger, + ); } } function deleteComponent(componentId: Component["id"]) { + const logger = useLogger(); const component = components.value[componentId]; delete components.value[componentId]; if (component) { - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "incrementMetric" in provider) { - try { - ( - provider as { - incrementMetric: ( - name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, - ) => void; - } - ).incrementMetric("component.deleted", { - tags: { - component_type: component.type, - }, - unit: "none", - }); - } catch (_e) { - // Ignore metric errors - } - } + incrementMetricSafely( + MetricName.ComponentDeleted, + { + tags: { + component_type: component.type, + }, + unit: MetricUnit.None, + }, + logger, + ); } } function addComponent(component: Component) { + const logger = useLogger(); components.value[component.id] = component; - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "incrementMetric" in provider) { - try { - ( - provider as { - incrementMetric: ( - name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, - ) => void; - } - ).incrementMetric("component.added", { - tags: { - component_type: component.type, - }, - unit: "none", - }); - } catch (_e) { - // Ignore metric errors - } - } + incrementMetricSafely( + MetricName.ComponentAdded, + { + tags: { + component_type: component.type, + }, + unit: MetricUnit.None, + }, + logger, + ); } /** diff --git a/src/ui/src/main.ts b/src/ui/src/main.ts index cd91e9e46..dbbbc93e3 100644 --- a/src/ui/src/main.ts +++ b/src/ui/src/main.ts @@ -13,7 +13,8 @@ import { CollaborationManager } from "./writerTypes.js"; import { useSecretsManager } from "./core/useSecretsManager.js"; import { RECONNECT_DELAY_MS, MAX_RETRIES } from "@/constants/retry"; import { observabilityRegistry } from "./observability"; -import { trackPageLoadTime, trackError } from "./observability/frontendMetrics"; +import { trackPageLoadTime } from "./observability/frontendMetrics"; +import { setupGlobalErrorHandling } from "./composables/useGlobalErrorHandling"; const wf = generateCore(); @@ -26,6 +27,8 @@ globalThis.core = wf; const logger = useLogger(); +setupGlobalErrorHandling(); + async function load() { await wf.init(); @@ -71,26 +74,6 @@ async function load() { trackPageLoadTime(); - if (typeof window !== "undefined") { - window.addEventListener("error", (event) => { - const error = - event.error || new Error(event.message || "Unknown error"); - trackError(error, error.name || "window_error"); - }); - - window.addEventListener("unhandledrejection", (event) => { - const error = - event.reason instanceof Error - ? event.reason - : new Error( - String( - event.reason || "Unhandled promise rejection", - ), - ); - trackError(error, "unhandled_promise_rejection"); - }); - } - if (wf.isWriterCloudApp.value && collaborationManager) { await enableCollaboration(collaborationManager).catch(logger.error); } diff --git a/src/ui/src/observability/base.ts b/src/ui/src/observability/base.ts index 7ac603fbe..6e0394846 100644 --- a/src/ui/src/observability/base.ts +++ b/src/ui/src/observability/base.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ export interface ObservabilityProvider { initialize(app?: unknown, router?: unknown): boolean | Promise; captureException( @@ -26,7 +27,6 @@ export class ObservabilityRegistry { register(name: string, provider: ObservabilityProvider): void { if (this.providers.has(name)) { - // eslint-disable-next-line no-console console.warn(`Overwriting existing provider '${name}'`); } @@ -46,41 +46,42 @@ export class ObservabilityRegistry { app?: unknown, router?: unknown, ): boolean | Promise { - if (name === null || name === undefined) { + let providerName: string | null | undefined = name; + + if (providerName === null || providerName === undefined) { const envProvider = import.meta.env.VITE_OBSERVABILITY_PROVIDER; if (envProvider) { - name = envProvider; + providerName = envProvider; } else { for (const [ - providerName, + enabledProviderName, provider, ] of this.providers.entries()) { if (provider.isEnabled()) { - name = providerName; + providerName = enabledProviderName; break; } } } } - if (!name) { - // eslint-disable-next-line no-console + if (!providerName) { console.info("No observability provider configured"); return false; } - const provider = this.getProvider(name); + const provider = this.getProvider(providerName); if (!provider) { - // eslint-disable-next-line no-console console.warn( - `Observability provider '${name}' not found. Available: ${this.listProviders().join(", ")}`, + `Observability provider '${providerName}' not found. Available: ${this.listProviders().join(", ")}`, ); return false; } if (!provider.isEnabled()) { - // eslint-disable-next-line no-console - console.info(`Observability provider '${name}' is disabled`); + console.info( + `Observability provider '${providerName}' is disabled`, + ); return false; } @@ -91,23 +92,20 @@ export class ObservabilityRegistry { .then((success) => { if (success) { this.initializedProvider = provider; - // eslint-disable-next-line no-console console.info( - `Initialized observability provider: ${name}`, + `Initialized observability provider: ${providerName}`, ); return true; } else { - // eslint-disable-next-line no-console console.warn( - `Failed to initialize observability provider: ${name}`, + `Failed to initialize observability provider: ${providerName}`, ); return false; } }) .catch((error) => { - // eslint-disable-next-line no-console console.error( - `Error initializing observability provider '${name}':`, + `Error initializing observability provider '${providerName}':`, error, ); return false; @@ -115,21 +113,20 @@ export class ObservabilityRegistry { } else { if (result) { this.initializedProvider = provider; - // eslint-disable-next-line no-console - console.info(`Initialized observability provider: ${name}`); + console.info( + `Initialized observability provider: ${providerName}`, + ); return true; } else { - // eslint-disable-next-line no-console console.warn( - `Failed to initialize observability provider: ${name}`, + `Failed to initialize observability provider: ${providerName}`, ); return false; } } } catch (error) { - // eslint-disable-next-line no-console console.error( - `Error initializing observability provider '${name}':`, + `Error initializing observability provider '${providerName}':`, error, ); return false; diff --git a/src/ui/src/observability/frontendMetrics.ts b/src/ui/src/observability/frontendMetrics.ts index ecc1444da..7ec6025de 100644 --- a/src/ui/src/observability/frontendMetrics.ts +++ b/src/ui/src/observability/frontendMetrics.ts @@ -1,5 +1,50 @@ import { observabilityRegistry } from "./index"; +export enum MetricName { + FrontendPageLoadTime = "frontend_page_load_time_ms", + FrontendErrorsTotal = "frontend_errors_total", + WebSocketLatency = "websocket_latency_ms", + FrontendRouteChangesTotal = "frontend_route_changes_total", + FrontendInteractionDuration = "frontend_interaction_duration_ms", + WebSocketMessageDuration = "websocket.message_duration", + WebSocketMessageError = "websocket.message_error", + ComponentDeleted = "component.deleted", + ComponentAdded = "component.added", +} + +export enum MetricType { + Performance = "performance", + Network = "network", +} + +export enum MetricUnit { + Millisecond = "millisecond", + None = "none", +} + +export interface RecordDistributionOptions { + tags?: Record; + unit?: string; +} + +export interface IncrementMetricOptions { + tags?: Record; + unit?: string; + value?: number; +} + +export type RecordDistributionProvider = { + recordDistribution: ( + name: string, + value: number, + options?: RecordDistributionOptions, + ) => void; +}; + +export type IncrementMetricProvider = { + incrementMetric: (name: string, options?: IncrementMetricOptions) => void; +}; + export function trackPageLoadTime(): void { if (typeof window === "undefined" || !window.performance) { return; @@ -10,26 +55,16 @@ export function trackPageLoadTime(): void { if (timing && timing.loadEventEnd && timing.navigationStart) { const loadTime = timing.loadEventEnd - timing.navigationStart; - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "recordDistribution" in provider) { - ( - provider as { - recordDistribution: ( - name: string, - value: number, - options?: { - tags?: Record; - unit?: string; - }, - ) => void; - } - ).recordDistribution("frontend_page_load_time_ms", loadTime, { + recordDistributionSafely( + MetricName.FrontendPageLoadTime, + loadTime, + { tags: { - metric_type: "performance", + metric_type: MetricType.Performance, }, - unit: "millisecond", - }); - } + unit: MetricUnit.Millisecond, + }, + ); } } catch (e) { // eslint-disable-next-line no-console @@ -38,122 +73,121 @@ export function trackPageLoadTime(): void { } export function trackError(error: Error, errorType?: string): void { - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "incrementMetric" in provider) { - try { - ( - provider as { - incrementMetric: ( - name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, - ) => void; - } - ).incrementMetric("frontend_errors_total", { - tags: { - error_type: errorType || error.name || "unknown", - error_message: - error.message?.substring(0, 100) || "unknown", - }, - unit: "none", - }); - } catch (e) { - // eslint-disable-next-line no-console - console.warn("Failed to track error metric:", e); - } - } + incrementMetricSafely(MetricName.FrontendErrorsTotal, { + tags: { + error_type: errorType || error.name || "unknown", + error_message: error.message?.substring(0, 100) || "unknown", + }, + unit: MetricUnit.None, + }); } export function trackWebSocketLatency(latencyMs: number): void { - const provider = observabilityRegistry.getInitializedProvider(); - if (provider && "recordDistribution" in provider) { - try { - ( - provider as { - recordDistribution: ( - name: string, - value: number, - options?: { - tags?: Record; - unit?: string; - }, - ) => void; - } - ).recordDistribution("websocket_latency_ms", latencyMs, { - tags: { - metric_type: "network", - }, - unit: "millisecond", - }); - } catch (e) { - // eslint-disable-next-line no-console - console.warn("Failed to track websocket latency:", e); - } - } + recordDistributionSafely( + MetricName.WebSocketLatency, + latencyMs, + { + tags: { + metric_type: MetricType.Network, + }, + unit: MetricUnit.Millisecond, + }, + ); } +let lastTrackedRoute: string | null = null; + export function trackRouteChange(route: string): void { + const normalizedRoute = route || "unknown"; + + if (lastTrackedRoute === normalizedRoute) { + return; + } + + lastTrackedRoute = normalizedRoute; + + incrementMetricSafely(MetricName.FrontendRouteChangesTotal, { + tags: { + route: normalizedRoute, + }, + unit: MetricUnit.None, + }); +} + +export function trackInteractionDuration( + actionType: string, + durationMs: number, +): void { + recordDistributionSafely( + MetricName.FrontendInteractionDuration, + durationMs, + { + tags: { + action_type: actionType, + }, + unit: MetricUnit.Millisecond, + }, + ); +} + +/** + * Safely increments a metric if the provider supports it. + * Handles the common pattern of checking for incrementMetric support and error handling. + * + * @param name The metric name to increment + * @param options Optional metric options (tags, unit, value) + * @param logger Optional logger for error reporting. If not provided, uses console.warn + */ +export function incrementMetricSafely( + name: string, + options?: IncrementMetricOptions, + logger?: { warn: (message: string, error?: unknown) => void }, +): void { const provider = observabilityRegistry.getInitializedProvider(); if (provider && "incrementMetric" in provider) { try { - ( - provider as { - incrementMetric: ( - name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, - ) => void; - } - ).incrementMetric("frontend_route_changes_total", { - tags: { - route: route || "unknown", - }, - unit: "none", - }); + (provider as IncrementMetricProvider).incrementMetric(name, options); } catch (e) { - // eslint-disable-next-line no-console - console.warn("Failed to track route change:", e); + if (logger) { + logger.warn("Failed to increment metric:", e); + } else { + // eslint-disable-next-line no-console + console.warn("Failed to increment metric:", e); + } } } } -export function trackInteractionDuration( - actionType: string, - durationMs: number, +/** + * Safely records a distribution metric if the provider supports it. + * Handles the common pattern of checking for recordDistribution support and error handling. + * + * @param name The metric name to record + * @param value The metric value to record + * @param options Optional metric options (tags, unit) + * @param logger Optional logger for error reporting. If not provided, uses console.warn + */ +export function recordDistributionSafely( + name: string, + value: number, + options?: RecordDistributionOptions, + logger?: { warn: (message: string, error?: unknown) => void }, ): void { const provider = observabilityRegistry.getInitializedProvider(); if (provider && "recordDistribution" in provider) { try { - ( - provider as { - recordDistribution: ( - name: string, - value: number, - options?: { - tags?: Record; - unit?: string; - }, - ) => void; - } - ).recordDistribution( - "frontend_interaction_duration_ms", - durationMs, - { - tags: { - action_type: actionType, - }, - unit: "millisecond", - }, + (provider as RecordDistributionProvider).recordDistribution( + name, + value, + options, ); } catch (e) { - // eslint-disable-next-line no-console - console.warn("Failed to track interaction duration:", e); + if (logger) { + logger.warn("Failed to record metric:", e); + } else { + // eslint-disable-next-line no-console + console.warn("Failed to record metric:", e); + } } } } diff --git a/src/ui/src/observability/sentryAdapter.ts b/src/ui/src/observability/sentryAdapter.ts index 08131b322..80a7a273c 100644 --- a/src/ui/src/observability/sentryAdapter.ts +++ b/src/ui/src/observability/sentryAdapter.ts @@ -1,4 +1,8 @@ +/* eslint-disable no-console */ import type { ObservabilityProvider } from "./base"; +import { trackRouteChange } from "./frontendMetrics"; +import { getParsedHash } from "@/core/navigation"; +import type * as SentryVue from "@sentry/vue"; export const SENTRY_DSN_ENV = "VITE_SENTRY_DSN"; export const SENTRY_ENABLED_ENV = "VITE_SENTRY_ENABLED"; @@ -7,55 +11,53 @@ export const SENTRY_TRACES_SAMPLE_RATE_ENV = "VITE_SENTRY_TRACES_SAMPLE_RATE"; export const SENTRY_REPLAY_SAMPLE_RATE_ENV = "VITE_SENTRY_REPLAY_SAMPLE_RATE"; interface SentryApi { - captureException: ( - error: Error, - options?: { - extra?: Record; - contexts?: Record; - }, - ) => void; - captureMessage: ( - message: string, - options?: { - level?: string; - extra?: Record; - contexts?: Record; - }, - ) => void; - setUser: (user: { - id?: string; - email?: string; - username?: string; - [key: string]: unknown; - }) => void; - setContext: (key: string, value: unknown) => void; + captureException: typeof SentryVue.captureException; + captureMessage: typeof SentryVue.captureMessage; + setUser: typeof SentryVue.setUser; + setContext: typeof SentryVue.setContext; +} + +interface SentryMetricData { + attributes?: Record; + unit?: string; +} + +interface BrowserTracingOptions { + router?: unknown; + tracingOrigins?: (string | RegExp)[]; + routeLabel?: "name" | "path"; +} + +interface AppMetadata { + agent_id?: string; + organization_id?: string; + mode?: string; + [key: string]: unknown; +} + +interface SentryMetricOptions { + tags?: Record; + unit?: string; +} + +interface SentryIncrementMetricOptions extends SentryMetricOptions { + value?: number; +} + +interface RuntimeContext { + url: string; + userAgent: string; + [key: string]: unknown; } interface SentryMetrics { - count: ( - name: string, - value?: number, - data?: { - attributes?: Record; - unit?: string; - }, - ) => void; + count: (name: string, value?: number, data?: SentryMetricData) => void; distribution: ( name: string, value: number, - data?: { - attributes?: Record; - unit?: string; - }, - ) => void; - gauge: ( - name: string, - value: number, - data?: { - attributes?: Record; - unit?: string; - }, + data?: SentryMetricData, ) => void; + gauge: (name: string, value: number, data?: SentryMetricData) => void; } interface SentryModule { @@ -67,23 +69,15 @@ interface SentryModule { setTag?: (key: string, value: string) => void; metrics?: SentryMetrics; vueIntegration?: (options?: { app?: unknown }) => unknown; - browserTracingIntegration?: (options?: { - router?: unknown; - tracingOrigins?: (string | RegExp)[]; - routeLabel?: "name" | "path"; - }) => unknown; - BrowserTracing?: new (options?: { - router?: unknown; - tracingOrigins?: (string | RegExp)[]; - routeLabel?: "name" | "path"; - }) => unknown; + browserTracingIntegration?: (options?: BrowserTracingOptions) => unknown; + BrowserTracing?: new (options?: BrowserTracingOptions) => unknown; } interface SentryInitConfig { dsn: string; environment: string; tracesSampleRate: number; - replay: { + replay?: { sampleRate: number; }; enableMetrics?: boolean; @@ -91,16 +85,8 @@ interface SentryInitConfig { integrations?: unknown[]; } -type BrowserTracingFunction = (options?: { - router?: unknown; - tracingOrigins?: (string | RegExp)[]; - routeLabel?: "name" | "path"; -}) => unknown; -type BrowserTracingClass = new (options?: { - router?: unknown; - tracingOrigins?: (string | RegExp)[]; - routeLabel?: "name" | "path"; -}) => unknown; +type BrowserTracingFunction = (options?: BrowserTracingOptions) => unknown; +type BrowserTracingClass = new (options?: BrowserTracingOptions) => unknown; type BrowserTracingIntegration = BrowserTracingFunction | BrowserTracingClass; export class SentryAdapter implements ObservabilityProvider { @@ -131,7 +117,6 @@ export class SentryAdapter implements ObservabilityProvider { try { const dsn = import.meta.env[SENTRY_DSN_ENV]; if (!dsn) { - // eslint-disable-next-line no-console console.info( "Sentry DSN not provided, skipping Sentry initialization", ); @@ -145,7 +130,6 @@ export class SentryAdapter implements ObservabilityProvider { ); SentryModule = sentryImport as unknown as SentryModule; } catch (importError) { - // eslint-disable-next-line no-console console.warn( "Failed to import @sentry/vue package. Make sure it's installed: npm install @sentry/vue", importError, @@ -190,7 +174,6 @@ export class SentryAdapter implements ObservabilityProvider { browserModule.browserTracingIntegration; } } catch { - // eslint-disable-next-line no-console console.warn( "BrowserTracing not available. Performance monitoring will be limited.", ); @@ -224,11 +207,7 @@ export class SentryAdapter implements ObservabilityProvider { } if (browserTracingIntegration) { - const tracingOptions: { - router?: unknown; - tracingOrigins?: (string | RegExp)[]; - routeLabel?: "name" | "path"; - } = { + const tracingOptions: BrowserTracingOptions = { tracingOrigins: ["localhost", /^\//], }; @@ -268,10 +247,8 @@ export class SentryAdapter implements ObservabilityProvider { this.metrics = this._getMetrics(SentryModule); if (this.metrics) { - // eslint-disable-next-line no-console console.debug("Sentry metrics API initialized"); } else { - // eslint-disable-next-line no-console console.warn( "Sentry metrics API not available (requires SDK 10.25.0+)", ); @@ -282,11 +259,9 @@ export class SentryAdapter implements ObservabilityProvider { this._setInitialMetadata(SentryModule); this._setupRouteTracking(); - // eslint-disable-next-line no-console console.info(`Sentry initialized (environment: ${environment})`); return true; } catch (error) { - // eslint-disable-next-line no-console console.warn( "Failed to load or initialize @sentry/vue package:", error, @@ -304,7 +279,7 @@ export class SentryAdapter implements ObservabilityProvider { } try { - const runtimeContext = { + const runtimeContext: RuntimeContext = { url: window.location.href, userAgent: navigator.userAgent, }; @@ -312,33 +287,32 @@ export class SentryAdapter implements ObservabilityProvider { if (error instanceof Error) { this.sentry.captureException(error, { extra: { - ...context, + ...(context as Record), url: window.location.href, timestamp: new Date().toISOString(), }, contexts: { runtime: runtimeContext, ...((context?.contexts as Record) || - {}), + ({} as Record)), }, - }); + } as Parameters[1]); } else { this.sentry.captureMessage(error, { level: "error", extra: { - ...context, + ...(context as Record), url: window.location.href, timestamp: new Date().toISOString(), }, contexts: { runtime: runtimeContext, ...((context?.contexts as Record) || - {}), + ({} as Record)), }, - }); + } as Parameters[1]); } } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to capture exception:", e); } } @@ -356,7 +330,7 @@ export class SentryAdapter implements ObservabilityProvider { this.sentry.captureMessage(message, { level, extra: { - ...context, + ...(context as Record), url: window.location.href, timestamp: new Date().toISOString(), }, @@ -364,12 +338,12 @@ export class SentryAdapter implements ObservabilityProvider { runtime: { url: window.location.href, userAgent: navigator.userAgent, - }, - ...((context?.contexts as Record) || {}), + } as RuntimeContext, + ...((context?.contexts as Record) || + ({} as Record)), }, - }); + } as Parameters[1]); } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to capture message:", e); } } @@ -388,7 +362,6 @@ export class SentryAdapter implements ObservabilityProvider { const { setUser } = this.sentry; setUser(user); } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to set user in Sentry:", e); } } @@ -400,9 +373,18 @@ export class SentryAdapter implements ObservabilityProvider { try { const { setContext } = this.sentry; - setContext(key, value); + // Sentry's setContext expects an object with string keys + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + setContext(key, value as Record); + } else { + // If value is not an object, wrap it in an object + setContext(key, { value }); + } } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to set context in Sentry:", e); } } @@ -442,7 +424,6 @@ export class SentryAdapter implements ObservabilityProvider { }); } } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to set initial Sentry metadata:", e); } } @@ -480,29 +461,35 @@ export class SentryAdapter implements ObservabilityProvider { if (typeof router.afterEach === "function") { router.afterEach((to) => { - const route = to.name || to.path || "unknown"; - this.incrementMetric("frontend_route_changes_total", { - tags: { route }, - unit: "none", - }); + let normalizedRoute: string; + if (to.name) { + normalizedRoute = to.name; + } else if (to.path) { + try { + const parsedHash = getParsedHash( + typeof window !== "undefined" + ? window.location.hash + : "", + ); + normalizedRoute = + parsedHash.pageKey || to.path || "unknown"; + } catch { + normalizedRoute = to.path; + } + } else { + normalizedRoute = "unknown"; + } + + trackRouteChange(normalizedRoute); }); } } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to set up Vue Router tracking:", e); } } - private _getAppMetadata(): { - agent_id?: string; - organization_id?: string; - mode?: string; - } { - const metadata: { - agent_id?: string; - organization_id?: string; - mode?: string; - } = {}; + private _getAppMetadata(): AppMetadata { + const metadata: AppMetadata = {}; try { if ( @@ -549,7 +536,6 @@ export class SentryAdapter implements ObservabilityProvider { } } } catch (e) { - // eslint-disable-next-line no-console console.debug("Failed to get app metadata:", e); } @@ -582,11 +568,7 @@ export class SentryAdapter implements ObservabilityProvider { incrementMetric( name: string, - options?: { - tags?: Record; - unit?: string; - value?: number; - }, + options?: SentryIncrementMetricOptions, ): void { if (!this.initialized) { return; @@ -603,7 +585,6 @@ export class SentryAdapter implements ObservabilityProvider { unit: options?.unit, }); } catch (e) { - // eslint-disable-next-line no-console console.warn(`Failed to send metric ${name}:`, e); } } @@ -611,10 +592,7 @@ export class SentryAdapter implements ObservabilityProvider { recordDistribution( name: string, value: number, - options?: { - tags?: Record; - unit?: string; - }, + options?: SentryMetricOptions, ): void { if (!this.initialized) { return; @@ -631,19 +609,11 @@ export class SentryAdapter implements ObservabilityProvider { unit: options?.unit, }); } catch (e) { - // eslint-disable-next-line no-console console.warn(`Failed to send distribution ${name}:`, e); } } - setGauge( - name: string, - value: number, - options?: { - tags?: Record; - unit?: string; - }, - ): void { + setGauge(name: string, value: number, options?: SentryMetricOptions): void { if (!this.initialized) { return; } @@ -659,7 +629,6 @@ export class SentryAdapter implements ObservabilityProvider { unit: options?.unit, }); } catch (e) { - // eslint-disable-next-line no-console console.warn(`Failed to send gauge ${name}:`, e); } } diff --git a/src/writer/observability/__init__.py b/src/writer/observability/__init__.py index 791c02ea1..bdf85df2e 100644 --- a/src/writer/observability/__init__.py +++ b/src/writer/observability/__init__.py @@ -13,7 +13,7 @@ "ObservabilityRegistry", ] -observability_registry = ObservabilityRegistry() +_observability_registry = ObservabilityRegistry() def _register_sentry_adapter(app_path: Optional[str] = None): @@ -21,7 +21,7 @@ def _register_sentry_adapter(app_path: Optional[str] = None): from writer.observability.sentry_adapter import SentryAdapter sentry_adapter = SentryAdapter(app_path=app_path) if sentry_adapter.is_enabled(): - observability_registry.register("sentry", sentry_adapter) + _observability_registry.register("sentry", sentry_adapter) __all__.append("SentryAdapter") except Exception as e: logger.debug(f"Sentry adapter not available: {e}") @@ -29,4 +29,4 @@ def _register_sentry_adapter(app_path: Optional[str] = None): def get_registry(app_path: Optional[str] = None): _register_sentry_adapter(app_path=app_path) - return observability_registry + return _observability_registry diff --git a/src/writer/observability/sentry_adapter.py b/src/writer/observability/sentry_adapter.py index 7d0513966..e82248a6c 100644 --- a/src/writer/observability/sentry_adapter.py +++ b/src/writer/observability/sentry_adapter.py @@ -136,7 +136,6 @@ async def dispatch(self, request: Request, call_next): scope.set_tag("http.method", request.method) scope.set_tag("http.path", request.url.path) - # Prefer header values over env vars for request context agent_id = request.headers.get("x-agent-id") or metadata.get("agent_id") org_id = request.headers.get("x-organization-id") or metadata.get("organization_id") From d113cbd374bcf18ba60a94fedf38221bc77a2b74 Mon Sep 17 00:00:00 2001 From: Rodion Chernyshov Date: Tue, 25 Nov 2025 11:59:33 +0100 Subject: [PATCH 4/6] fix: Implement FE/BE crash reports & metrix --- .../src/composables/useBlueprintNodeTools.ts | 1 + src/ui/src/composables/useWriterTracking.ts | 1 - src/ui/src/core/index.ts | 4 -- src/ui/src/observability/frontendMetrics.ts | 71 +++++++------------ 4 files changed, 27 insertions(+), 50 deletions(-) diff --git a/src/ui/src/composables/useBlueprintNodeTools.ts b/src/ui/src/composables/useBlueprintNodeTools.ts index f665ffa54..7263b38a4 100644 --- a/src/ui/src/composables/useBlueprintNodeTools.ts +++ b/src/ui/src/composables/useBlueprintNodeTools.ts @@ -72,3 +72,4 @@ export function useBlueprintNodeTools( }; } + diff --git a/src/ui/src/composables/useWriterTracking.ts b/src/ui/src/composables/useWriterTracking.ts index 7feec7341..42f9a0fe0 100644 --- a/src/ui/src/composables/useWriterTracking.ts +++ b/src/ui/src/composables/useWriterTracking.ts @@ -197,7 +197,6 @@ export function useWriterTracking(wf: ReturnType) { }, unit: MetricUnit.None, }, - logger, ); return await Promise.all([ diff --git a/src/ui/src/core/index.ts b/src/ui/src/core/index.ts index e32e890ed..aace687f3 100644 --- a/src/ui/src/core/index.ts +++ b/src/ui/src/core/index.ts @@ -797,7 +797,6 @@ export function generateCore() { }, unit: MetricUnit.Millisecond, }, - logger, ); } catch (error) { logger.error("sendFrontendMessage error", error); @@ -811,7 +810,6 @@ export function generateCore() { }, unit: MetricUnit.None, }, - logger, ); } } @@ -830,7 +828,6 @@ export function generateCore() { }, unit: MetricUnit.None, }, - logger, ); } } @@ -847,7 +844,6 @@ export function generateCore() { }, unit: MetricUnit.None, }, - logger, ); } diff --git a/src/ui/src/observability/frontendMetrics.ts b/src/ui/src/observability/frontendMetrics.ts index 7ec6025de..a78c10b09 100644 --- a/src/ui/src/observability/frontendMetrics.ts +++ b/src/ui/src/observability/frontendMetrics.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { observabilityRegistry } from "./index"; export enum MetricName { @@ -51,10 +52,21 @@ export function trackPageLoadTime(): void { } try { - const timing = window.performance.timing; - if (timing && timing.loadEventEnd && timing.navigationStart) { - const loadTime = timing.loadEventEnd - timing.navigationStart; + let loadTime: number | null = null; + + const navigationEntries = performance.getEntriesByType( + "navigation", + ) as PerformanceNavigationTiming[]; + if (navigationEntries.length > 0) { + const entry = navigationEntries[0]; + if (entry.loadEventEnd && entry.startTime) { + loadTime = entry.loadEventEnd - entry.startTime; + } else if (entry.duration) { + loadTime = entry.duration; + } + } + if (loadTime !== null) { recordDistributionSafely( MetricName.FrontendPageLoadTime, loadTime, @@ -67,7 +79,6 @@ export function trackPageLoadTime(): void { ); } } catch (e) { - // eslint-disable-next-line no-console console.warn("Failed to track page load time:", e); } } @@ -83,16 +94,12 @@ export function trackError(error: Error, errorType?: string): void { } export function trackWebSocketLatency(latencyMs: number): void { - recordDistributionSafely( - MetricName.WebSocketLatency, - latencyMs, - { - tags: { - metric_type: MetricType.Network, - }, - unit: MetricUnit.Millisecond, + recordDistributionSafely(MetricName.WebSocketLatency, latencyMs, { + tags: { + metric_type: MetricType.Network, }, - ); + unit: MetricUnit.Millisecond, + }); } let lastTrackedRoute: string | null = null; @@ -130,48 +137,27 @@ export function trackInteractionDuration( ); } -/** - * Safely increments a metric if the provider supports it. - * Handles the common pattern of checking for incrementMetric support and error handling. - * - * @param name The metric name to increment - * @param options Optional metric options (tags, unit, value) - * @param logger Optional logger for error reporting. If not provided, uses console.warn - */ export function incrementMetricSafely( name: string, options?: IncrementMetricOptions, - logger?: { warn: (message: string, error?: unknown) => void }, ): void { const provider = observabilityRegistry.getInitializedProvider(); if (provider && "incrementMetric" in provider) { try { - (provider as IncrementMetricProvider).incrementMetric(name, options); + (provider as IncrementMetricProvider).incrementMetric( + name, + options, + ); } catch (e) { - if (logger) { - logger.warn("Failed to increment metric:", e); - } else { - // eslint-disable-next-line no-console - console.warn("Failed to increment metric:", e); - } + console.warn("Failed to increment metric:", e); } } } -/** - * Safely records a distribution metric if the provider supports it. - * Handles the common pattern of checking for recordDistribution support and error handling. - * - * @param name The metric name to record - * @param value The metric value to record - * @param options Optional metric options (tags, unit) - * @param logger Optional logger for error reporting. If not provided, uses console.warn - */ export function recordDistributionSafely( name: string, value: number, options?: RecordDistributionOptions, - logger?: { warn: (message: string, error?: unknown) => void }, ): void { const provider = observabilityRegistry.getInitializedProvider(); if (provider && "recordDistribution" in provider) { @@ -182,12 +168,7 @@ export function recordDistributionSafely( options, ); } catch (e) { - if (logger) { - logger.warn("Failed to record metric:", e); - } else { - // eslint-disable-next-line no-console - console.warn("Failed to record metric:", e); - } + console.warn("Failed to record metric:", e); } } } From 9624b0dd99a9285dd774a3ffa22b99a668644ba7 Mon Sep 17 00:00:00 2001 From: Rodion Chernyshov Date: Tue, 25 Nov 2025 12:18:23 +0100 Subject: [PATCH 5/6] fix: Implement FE/BE crash reports & metrix --- src/ui/src/observability/sentryAdapter.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/ui/src/observability/sentryAdapter.ts b/src/ui/src/observability/sentryAdapter.ts index 80a7a273c..9a70ef9fa 100644 --- a/src/ui/src/observability/sentryAdapter.ts +++ b/src/ui/src/observability/sentryAdapter.ts @@ -5,10 +5,6 @@ import { getParsedHash } from "@/core/navigation"; import type * as SentryVue from "@sentry/vue"; export const SENTRY_DSN_ENV = "VITE_SENTRY_DSN"; -export const SENTRY_ENABLED_ENV = "VITE_SENTRY_ENABLED"; -export const SENTRY_ENVIRONMENT_ENV = "VITE_SENTRY_ENVIRONMENT"; -export const SENTRY_TRACES_SAMPLE_RATE_ENV = "VITE_SENTRY_TRACES_SAMPLE_RATE"; -export const SENTRY_REPLAY_SAMPLE_RATE_ENV = "VITE_SENTRY_REPLAY_SAMPLE_RATE"; interface SentryApi { captureException: typeof SentryVue.captureException; @@ -98,7 +94,7 @@ export class SentryAdapter implements ObservabilityProvider { private router: unknown = null; isEnabled(): boolean { - return import.meta.env[SENTRY_ENABLED_ENV] !== "false"; + return !!import.meta.env[SENTRY_DSN_ENV]; } getName(): string { @@ -180,20 +176,19 @@ export class SentryAdapter implements ObservabilityProvider { } } - const environment = - import.meta.env[SENTRY_ENVIRONMENT_ENV] || - import.meta.env.MODE || - "production"; + const apiBaseUrl = + import.meta.env.VITE_WRITER_BASE_URL ?? window.location.origin; + const tracesSampleRate = parseFloat( - import.meta.env[SENTRY_TRACES_SAMPLE_RATE_ENV] || "1.0", + import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE || "0.1", ); const replaySampleRate = parseFloat( - import.meta.env[SENTRY_REPLAY_SAMPLE_RATE_ENV] || "0.1", + import.meta.env.VITE_SENTRY_REPLAY_SAMPLE_RATE || "0.1", ); const initConfig: SentryInitConfig = { dsn, - environment, + environment: apiBaseUrl, tracesSampleRate, replay: { sampleRate: replaySampleRate, @@ -259,7 +254,7 @@ export class SentryAdapter implements ObservabilityProvider { this._setInitialMetadata(SentryModule); this._setupRouteTracking(); - console.info(`Sentry initialized (environment: ${environment})`); + console.info(`Sentry initialized (environment: ${apiBaseUrl})`); return true; } catch (error) { console.warn( From 4912adf84bf25da5530697c534588ca899377853 Mon Sep 17 00:00:00 2001 From: Rodion Chernyshov Date: Tue, 25 Nov 2025 18:50:29 +0100 Subject: [PATCH 6/6] fix: Integrate config js file support for Cloud app --- src/ui/src/observability/frontendMetrics.ts | 12 ++++++ src/ui/src/observability/sentryAdapter.ts | 44 ++++++++++++++++++--- src/ui/vite.config.ts | 6 --- src/writer/app_runner.py | 8 ++++ src/writer/blueprints.py | 38 ++++++++++++++++++ src/writer/core.py | 27 +++++++++++++ src/writer/observability/sentry_adapter.py | 23 ++++++++--- src/writer/serve.py | 20 ++++++++++ 8 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/ui/src/observability/frontendMetrics.ts b/src/ui/src/observability/frontendMetrics.ts index a78c10b09..2140634db 100644 --- a/src/ui/src/observability/frontendMetrics.ts +++ b/src/ui/src/observability/frontendMetrics.ts @@ -91,6 +91,18 @@ export function trackError(error: Error, errorType?: string): void { }, unit: MetricUnit.None, }); + + const provider = observabilityRegistry.getInitializedProvider(); + if (provider) { + try { + provider.captureException(error, { + source: "global_error_handler", + error_type: errorType || error.name || "unknown", + }); + } catch (e) { + console.warn("Failed to send error to Sentry:", e); + } + } } export function trackWebSocketLatency(latencyMs: number): void { diff --git a/src/ui/src/observability/sentryAdapter.ts b/src/ui/src/observability/sentryAdapter.ts index 9a70ef9fa..7f5dcecfe 100644 --- a/src/ui/src/observability/sentryAdapter.ts +++ b/src/ui/src/observability/sentryAdapter.ts @@ -106,6 +106,13 @@ export class SentryAdapter implements ObservabilityProvider { return true; } + if (!this.isEnabled()) { + console.info( + "Sentry DSN not provided, skipping Sentry initialization", + ); + return false; + } + if (router) { this.router = router; } @@ -176,9 +183,6 @@ export class SentryAdapter implements ObservabilityProvider { } } - const apiBaseUrl = - import.meta.env.VITE_WRITER_BASE_URL ?? window.location.origin; - const tracesSampleRate = parseFloat( import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE || "0.1", ); @@ -188,7 +192,7 @@ export class SentryAdapter implements ObservabilityProvider { const initConfig: SentryInitConfig = { dsn, - environment: apiBaseUrl, + environment: "production", tracesSampleRate, replay: { sampleRate: replaySampleRate, @@ -254,7 +258,7 @@ export class SentryAdapter implements ObservabilityProvider { this._setInitialMetadata(SentryModule); this._setupRouteTracking(); - console.info(`Sentry initialized (environment: ${apiBaseUrl})`); + console.info("Sentry initialized"); return true; } catch (error) { console.warn( @@ -281,6 +285,12 @@ export class SentryAdapter implements ObservabilityProvider { if (error instanceof Error) { this.sentry.captureException(error, { + tags: { + platform: "frontend", + component: "frontend", + layer: "client", + ...(context?.tags as Record), + }, extra: { ...(context as Record), url: window.location.href, @@ -288,6 +298,12 @@ export class SentryAdapter implements ObservabilityProvider { }, contexts: { runtime: runtimeContext, + component: { + type: "frontend", + platform: "frontend", + layer: "client", + ...(context?.source && { source: context.source }), + }, ...((context?.contexts as Record) || ({} as Record)), }, @@ -324,6 +340,12 @@ export class SentryAdapter implements ObservabilityProvider { try { this.sentry.captureMessage(message, { level, + tags: { + platform: "frontend", + component: "frontend", + layer: "client", + ...(context?.tags as Record), + }, extra: { ...(context as Record), url: window.location.href, @@ -334,6 +356,11 @@ export class SentryAdapter implements ObservabilityProvider { url: window.location.href, userAgent: navigator.userAgent, } as RuntimeContext, + component: { + type: "frontend", + platform: "frontend", + layer: "client", + }, ...((context?.contexts as Record) || ({} as Record)), }, @@ -392,6 +419,8 @@ export class SentryAdapter implements ObservabilityProvider { try { if (SentryModule.setTag) { SentryModule.setTag("platform", "frontend"); + SentryModule.setTag("component", "frontend"); + SentryModule.setTag("layer", "client"); SentryModule.setTag("framework", "writer-framework"); const appMetadata = this._getAppMetadata(); @@ -417,6 +446,11 @@ export class SentryAdapter implements ObservabilityProvider { user_agent: navigator.userAgent, language: navigator.language, }); + SentryModule.setContext("component", { + type: "frontend", + platform: "frontend", + layer: "client", + }); } } catch (e) { console.warn("Failed to set initial Sentry metadata:", e); diff --git a/src/ui/vite.config.ts b/src/ui/vite.config.ts index 14e737267..a701a1196 100644 --- a/src/ui/vite.config.ts +++ b/src/ui/vite.config.ts @@ -15,12 +15,6 @@ export default defineConfig({ process.env.WRITER_FRAMEWORK_VERSION || "", ), VITE_SENTRY_DSN: JSON.stringify(process.env.VITE_SENTRY_DSN || ""), - VITE_SENTRY_ENABLED: JSON.stringify( - process.env.VITE_SENTRY_ENABLED || "false", - ), - VITE_SENTRY_ENVIRONMENT: JSON.stringify( - process.env.VITE_SENTRY_ENVIRONMENT || "", - ), VITE_SENTRY_TRACES_SAMPLE_RATE: JSON.stringify( process.env.VITE_SENTRY_TRACES_SAMPLE_RATE || "1.0", ), diff --git a/src/writer/app_runner.py b/src/writer/app_runner.py index ccbf5b6bf..9dd4f78dc 100644 --- a/src/writer/app_runner.py +++ b/src/writer/app_runner.py @@ -499,6 +499,14 @@ def _main(self) -> None: self._apply_configuration() import os + try: + from writer.observability import get_registry + observability_registry = get_registry(app_path=self.app_path) + if observability_registry.initialize_provider(): + self.logger.info("Sentry initialized in app process") + except Exception as e: + self.logger.warning(f"Failed to initialize Sentry in app process: {e}") + os.chdir(self.app_path) self._load_module() # Allows for relative imports from the app's path diff --git a/src/writer/blueprints.py b/src/writer/blueprints.py index 97bc19b32..96e63d9d1 100644 --- a/src/writer/blueprints.py +++ b/src/writer/blueprints.py @@ -17,6 +17,8 @@ from writer.journal import JournalRecord from writer.ss_types import BlueprintExecutionError, BlueprintExecutionLog, WriterConfigurationError +logger = logging.getLogger(__name__) + MAX_DAG_DEPTH = 32 MAX_LOG_ITERABLE_SIZE = 100 MAX_LOG_STRING_LENGTH = 5000 @@ -774,11 +776,47 @@ def _execute(self, executor: ThreadPoolExecutor, abort_event: threading.Event) - try: result_node: GraphNode = future.result() except BlueprintExecutionError as e: + try: + import sentry_sdk + with sentry_sdk.push_scope() as scope: + scope.set_tag("source", "blueprint_execution") + scope.set_tag("error_type", "BlueprintExecutionError") + scope.set_context("blueprint", { + "run_id": self.run_id, + "title": self.status_logger.title, + }) + sentry_sdk.capture_exception(e) + except Exception: + pass + self._cancel_all_jobs() self.status_logger.log("Execution failed", entry_type="error", exit=str(e)) journal_record.save(result="error") raise e except BaseException as e: + # Capture all other exceptions in Sentry + try: + import sentry_sdk + with sentry_sdk.push_scope() as scope: + scope.set_tag("platform", "backend") + scope.set_tag("component", "backend") + scope.set_tag("layer", "server") + scope.set_tag("source", "blueprint_execution") + scope.set_tag("error_type", type(e).__name__) + scope.set_context("blueprint", { + "run_id": self.run_id, + "title": self.status_logger.title, + }) + scope.set_context("component", { + "type": "backend", + "platform": "backend", + "layer": "server", + "source": "blueprint_execution", + }) + sentry_sdk.capture_exception(e) + except Exception: + pass + abort_event.set() self._cancel_all_jobs() self.status_logger.log("Execution failed.", entry_type="error", exit=str(e)) diff --git a/src/writer/core.py b/src/writer/core.py index 586b49f0e..424a1e57c 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -1889,6 +1889,33 @@ def _handle_global_event(self, ev: WriterEvent): calling_arguments = self._get_calling_arguments(ev, instance_path=None) return self._call_handler_callable(handler_callable, calling_arguments) except BaseException as e: + try: + import sentry_sdk + with sentry_sdk.push_scope() as scope: + scope.set_tag("platform", "backend") + scope.set_tag("component", "backend") + scope.set_tag("layer", "server") + scope.set_tag("source", "event_handler") + scope.set_tag("event_type", ev.type) + scope.set_context("event", { + "type": ev.type, + "handler": ev.handler, + "is_safe": ev.isSafe, + }) + scope.set_context("component", { + "type": "backend", + "platform": "backend", + "layer": "server", + "source": "event_handler", + }) + event_id = sentry_sdk.capture_exception(e) + if event_id: + logging.info(f"Error sent to Sentry (event_id: {event_id}, event_type: {ev.type})") + except ImportError: + logging.warning("Sentry SDK not available - error will not be sent to Sentry") + except Exception as sentry_error: + logging.error(f"Failed to send error to Sentry: {sentry_error}", exc_info=True) + if not isinstance(e, BlueprintExecutionError): # Only create a notification and log entry # for non-blueprint errors, as blueprint errors diff --git a/src/writer/observability/sentry_adapter.py b/src/writer/observability/sentry_adapter.py index e82248a6c..5bfc28df6 100644 --- a/src/writer/observability/sentry_adapter.py +++ b/src/writer/observability/sentry_adapter.py @@ -27,7 +27,9 @@ def __init__(self, app_path: Optional[str] = None): def is_enabled(self) -> bool: """Check if Sentry is enabled.""" - return os.getenv(SENTRY_ENABLED_ENV, "true").lower() != "false" + sentry_enabled = os.getenv(SENTRY_ENABLED_ENV, "true").lower() != "false" + sentry_dsn = os.getenv(SENTRY_DSN_ENV) + return sentry_enabled and bool(sentry_dsn) def _get_metadata(self) -> dict: """Get application metadata for Sentry tags/contexts.""" @@ -46,16 +48,21 @@ def initialize(self) -> bool: return True if not self.is_enabled(): - logger.debug("Sentry is disabled via environment variable") + logger.debug("Sentry is disabled - check SENTRY_ENABLED and SENTRY_DSN environment variables") return False try: import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration + except ImportError as e: + logger.warning(f"Sentry SDK not available. Install it with: pip install sentry-sdk. Error: {e}") + return False + try: sentry_dsn = os.getenv(SENTRY_DSN_ENV) if not sentry_dsn: - logger.debug("Sentry DSN not provided, skipping initialization") + logger.warning(f"Sentry DSN not provided in {SENTRY_DSN_ENV} environment variable, skipping initialization") + logger.info("Set SENTRY_DSN environment variable to enable Sentry") return False self._agent_id = os.getenv("WRITER_APP_ID") @@ -89,6 +96,8 @@ def before_send(event, hint): # Set global tags and contexts with sentry_sdk.configure_scope() as scope: scope.set_tag("platform", "backend") + scope.set_tag("component", "backend") + scope.set_tag("layer", "server") scope.set_tag("framework", "writer-framework") if metadata["agent_id"]: scope.set_tag("agent_id", metadata["agent_id"]) @@ -104,14 +113,16 @@ def before_send(event, hint): "name": "python", "version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", }) + scope.set_context("component", { + "type": "backend", + "platform": "backend", + "layer": "server", + }) logger.info(f"Sentry initialized (environment: {environment})") self._initialized = True return True - except ImportError: - logger.debug("Sentry SDK not available") - return False except Exception as e: logger.error(f"Failed to initialize Sentry: {e}", exc_info=True) return False diff --git a/src/writer/serve.py b/src/writer/serve.py index 2c896ef65..7f1f27170 100644 --- a/src/writer/serve.py +++ b/src/writer/serve.py @@ -600,6 +600,26 @@ async def event_logic(queue: asyncio.Queue): })) except Exception as e: + try: + import sentry_sdk + with sentry_sdk.push_scope() as scope: + scope.set_tag("platform", "backend") + scope.set_tag("component", "backend") + scope.set_tag("layer", "server") + scope.set_tag("source", "blueprint_api_endpoint") + scope.set_tag("blueprint_id", blueprint_id) + if branch_id: + scope.set_tag("branch_id", branch_id) + scope.set_context("component", { + "type": "backend", + "platform": "backend", + "layer": "server", + "source": "blueprint_api_endpoint", + }) + sentry_sdk.capture_exception(e) + except Exception: + pass + # Bubble up any unexpected error as 'error' SSE event await queue.put(await format_event("error", { "msg": f"Agent Builder internal error: {str(e)}",