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/useBlueprintNodeTools.ts b/src/ui/src/composables/useBlueprintNodeTools.ts index af68804b2..7263b38a4 100644 --- a/src/ui/src/composables/useBlueprintNodeTools.ts +++ b/src/ui/src/composables/useBlueprintNodeTools.ts @@ -71,3 +71,5 @@ 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 5eaed107a..268ec0e1f 100644 --- a/src/ui/src/composables/useLogger.ts +++ b/src/ui/src/composables/useLogger.ts @@ -1,15 +1,40 @@ /* 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, + 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), + }); + } + }, + 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..42f9a0fe0 100644 --- a/src/ui/src/composables/useWriterTracking.ts +++ b/src/ui/src/composables/useWriterTracking.ts @@ -2,6 +2,10 @@ import type { generateCore } from "@/core"; import { useWriterApi } from "./useWriterApi"; import { watch } from "vue"; import { useLogger } from "./useLogger"; +import { + MetricUnit, + incrementMetricSafely, +} from "@/observability/frontendMetrics"; let isIdentified = false; @@ -185,6 +189,16 @@ export function useWriterTracking(wf: ReturnType) { expandEventPropertiesWithResources(properties); logger.log("[tracking]", eventNameFormated, propertiesExpanded); + incrementMetricSafely( + `user_action.${eventName}`, + { + tags: { + event_type: eventName, + }, + unit: MetricUnit.None, + }, + ); + 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..aace687f3 100644 --- a/src/ui/src/core/index.ts +++ b/src/ui/src/core/index.ts @@ -33,6 +33,13 @@ import { bigIntReplacer } from "./serializer"; import { useLogger } from "@/composables/useLogger"; import { readBlobAsArrayBufferJson } from "@/utils/blob"; import { RECONNECT_DELAY_MS } from "@/constants/retry"; +import { MetricName, MetricUnit } from "@/observability/frontendMetrics"; +import { + trackWebSocketLatency, + trackInteractionDuration, + incrementMetricSafely, + recordDistributionSafely, +} from "@/observability/frontendMetrics"; import { createFileToSourceFiles, deleteFileToSourceFiles, @@ -185,7 +192,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 +745,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 +786,65 @@ export function generateCore() { throw "Connection lost."; } webSocket.send(JSON.stringify(wsData, bigIntReplacer)); + + const messageDuration = performance.now() - messageStartTime; + recordDistributionSafely( + MetricName.WebSocketMessageDuration, + messageDuration, + { + tags: { + message_type: type, + }, + unit: MetricUnit.Millisecond, + }, + ); } catch (error) { logger.error("sendFrontendMessage error", error); callback?.({ ok: false }); + + incrementMetricSafely( + MetricName.WebSocketMessageError, + { + tags: { + message_type: type, + }, + unit: MetricUnit.None, + }, + ); } } function deleteComponent(componentId: Component["id"]) { + const logger = useLogger(); + const component = components.value[componentId]; delete components.value[componentId]; + + if (component) { + incrementMetricSafely( + MetricName.ComponentDeleted, + { + tags: { + component_type: component.type, + }, + unit: MetricUnit.None, + }, + ); + } } function addComponent(component: Component) { + const logger = useLogger(); components.value[component.id] = component; + + incrementMetricSafely( + MetricName.ComponentAdded, + { + tags: { + component_type: component.type, + }, + unit: MetricUnit.None, + }, + ); } /** 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..dbbbc93e3 100644 --- a/src/ui/src/main.ts +++ b/src/ui/src/main.ts @@ -12,6 +12,9 @@ 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 } from "./observability/frontendMetrics"; +import { setupGlobalErrorHandling } from "./composables/useGlobalErrorHandling"; const wf = generateCore(); @@ -24,6 +27,8 @@ globalThis.core = wf; const logger = useLogger(); +setupGlobalErrorHandling(); + async function load() { await wf.init(); @@ -59,8 +64,16 @@ 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 (wf.isWriterCloudApp.value && collaborationManager) { await enableCollaboration(collaborationManager).catch(logger.error); } @@ -73,7 +86,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 +144,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..6e0394846 --- /dev/null +++ b/src/ui/src/observability/base.ts @@ -0,0 +1,158 @@ +/* eslint-disable no-console */ +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)) { + 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 { + let providerName: string | null | undefined = name; + + if (providerName === null || providerName === undefined) { + const envProvider = import.meta.env.VITE_OBSERVABILITY_PROVIDER; + if (envProvider) { + providerName = envProvider; + } else { + for (const [ + enabledProviderName, + provider, + ] of this.providers.entries()) { + if (provider.isEnabled()) { + providerName = enabledProviderName; + break; + } + } + } + } + + if (!providerName) { + console.info("No observability provider configured"); + return false; + } + + const provider = this.getProvider(providerName); + if (!provider) { + console.warn( + `Observability provider '${providerName}' not found. Available: ${this.listProviders().join(", ")}`, + ); + return false; + } + + if (!provider.isEnabled()) { + console.info( + `Observability provider '${providerName}' is disabled`, + ); + return false; + } + + try { + const result = provider.initialize(app, router); + if (result instanceof Promise) { + return result + .then((success) => { + if (success) { + this.initializedProvider = provider; + console.info( + `Initialized observability provider: ${providerName}`, + ); + return true; + } else { + console.warn( + `Failed to initialize observability provider: ${providerName}`, + ); + return false; + } + }) + .catch((error) => { + console.error( + `Error initializing observability provider '${providerName}':`, + error, + ); + return false; + }); + } else { + if (result) { + this.initializedProvider = provider; + console.info( + `Initialized observability provider: ${providerName}`, + ); + return true; + } else { + console.warn( + `Failed to initialize observability provider: ${providerName}`, + ); + return false; + } + } + } catch (error) { + console.error( + `Error initializing observability provider '${providerName}':`, + 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..2140634db --- /dev/null +++ b/src/ui/src/observability/frontendMetrics.ts @@ -0,0 +1,186 @@ +/* eslint-disable no-console */ +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; + } + + try { + 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, + { + tags: { + metric_type: MetricType.Performance, + }, + unit: MetricUnit.Millisecond, + }, + ); + } + } catch (e) { + console.warn("Failed to track page load time:", e); + } +} + +export function trackError(error: Error, errorType?: string): void { + incrementMetricSafely(MetricName.FrontendErrorsTotal, { + tags: { + error_type: errorType || error.name || "unknown", + error_message: error.message?.substring(0, 100) || "unknown", + }, + 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 { + 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, + }, + ); +} + +export function incrementMetricSafely( + name: string, + options?: IncrementMetricOptions, +): void { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "incrementMetric" in provider) { + try { + (provider as IncrementMetricProvider).incrementMetric( + name, + options, + ); + } catch (e) { + console.warn("Failed to increment metric:", e); + } + } +} + +export function recordDistributionSafely( + name: string, + value: number, + options?: RecordDistributionOptions, +): void { + const provider = observabilityRegistry.getInitializedProvider(); + if (provider && "recordDistribution" in provider) { + try { + (provider as RecordDistributionProvider).recordDistribution( + name, + value, + options, + ); + } catch (e) { + console.warn("Failed to record metric:", 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..7f5dcecfe --- /dev/null +++ b/src/ui/src/observability/sentryAdapter.ts @@ -0,0 +1,664 @@ +/* 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"; + +interface SentryApi { + 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?: SentryMetricData) => void; + distribution: ( + name: string, + value: number, + data?: SentryMetricData, + ) => void; + gauge: (name: string, value: number, data?: SentryMetricData) => 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?: BrowserTracingOptions) => unknown; + BrowserTracing?: new (options?: BrowserTracingOptions) => unknown; +} + +interface SentryInitConfig { + dsn: string; + environment: string; + tracesSampleRate: number; + replay?: { + sampleRate: number; + }; + enableMetrics?: boolean; + app?: unknown; + integrations?: unknown[]; +} + +type BrowserTracingFunction = (options?: BrowserTracingOptions) => unknown; +type BrowserTracingClass = new (options?: BrowserTracingOptions) => 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_DSN_ENV]; + } + + getName(): string { + return "sentry"; + } + + async initialize(app?: unknown, router?: unknown): Promise { + if (this.initialized) { + return true; + } + + if (!this.isEnabled()) { + console.info( + "Sentry DSN not provided, skipping Sentry initialization", + ); + return false; + } + + if (router) { + this.router = router; + } + + try { + const dsn = import.meta.env[SENTRY_DSN_ENV]; + if (!dsn) { + 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) { + 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 { + console.warn( + "BrowserTracing not available. Performance monitoring will be limited.", + ); + } + } + + const tracesSampleRate = parseFloat( + import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE || "0.1", + ); + const replaySampleRate = parseFloat( + import.meta.env.VITE_SENTRY_REPLAY_SAMPLE_RATE || "0.1", + ); + + const initConfig: SentryInitConfig = { + dsn, + environment: "production", + tracesSampleRate, + replay: { + sampleRate: replaySampleRate, + }, + enableMetrics: true, + }; + + if (app) { + initConfig.app = app; + this.app = app; + } + + if (browserTracingIntegration) { + const tracingOptions: BrowserTracingOptions = { + 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) { + console.debug("Sentry metrics API initialized"); + } else { + console.warn( + "Sentry metrics API not available (requires SDK 10.25.0+)", + ); + } + + this.initialized = true; + + this._setInitialMetadata(SentryModule); + this._setupRouteTracking(); + + console.info("Sentry initialized"); + return true; + } catch (error) { + 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: RuntimeContext = { + url: window.location.href, + userAgent: navigator.userAgent, + }; + + 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, + timestamp: new Date().toISOString(), + }, + contexts: { + runtime: runtimeContext, + component: { + type: "frontend", + platform: "frontend", + layer: "client", + ...(context?.source && { source: context.source }), + }, + ...((context?.contexts as Record) || + ({} as Record)), + }, + } as Parameters[1]); + } else { + this.sentry.captureMessage(error, { + level: "error", + extra: { + ...(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) { + 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, + tags: { + platform: "frontend", + component: "frontend", + layer: "client", + ...(context?.tags as Record), + }, + extra: { + ...(context as Record), + url: window.location.href, + timestamp: new Date().toISOString(), + }, + contexts: { + runtime: { + url: window.location.href, + userAgent: navigator.userAgent, + } as RuntimeContext, + component: { + type: "frontend", + platform: "frontend", + layer: "client", + }, + ...((context?.contexts as Record) || + ({} as Record)), + }, + } as Parameters[1]); + } catch (e) { + 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) { + 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; + // 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) { + 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("component", "frontend"); + SentryModule.setTag("layer", "client"); + 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, + }); + SentryModule.setContext("component", { + type: "frontend", + platform: "frontend", + layer: "client", + }); + } + } catch (e) { + 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) => { + 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) { + console.warn("Failed to set up Vue Router tracking:", e); + } + } + + private _getAppMetadata(): AppMetadata { + const metadata: AppMetadata = {}; + + 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) { + 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?: SentryIncrementMetricOptions, + ): 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) { + console.warn(`Failed to send metric ${name}:`, e); + } + } + + recordDistribution( + name: string, + value: number, + options?: SentryMetricOptions, + ): 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) { + console.warn(`Failed to send distribution ${name}:`, e); + } + } + + setGauge(name: string, value: number, options?: SentryMetricOptions): 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) { + console.warn(`Failed to send gauge ${name}:`, e); + } + } +} diff --git a/src/ui/vite.config.ts b/src/ui/vite.config.ts index 7707e4692..a701a1196 100644 --- a/src/ui/vite.config.ts +++ b/src/ui/vite.config.ts @@ -11,7 +11,16 @@ 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_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/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/__init__.py b/src/writer/observability/__init__.py new file mode 100644 index 000000000..bdf85df2e --- /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..5bfc28df6 --- /dev/null +++ b/src/writer/observability/sentry_adapter.py @@ -0,0 +1,187 @@ +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.""" + 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.""" + 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 - 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.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") + 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("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"]) + 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}", + }) + scope.set_context("component", { + "type": "backend", + "platform": "backend", + "layer": "server", + }) + + logger.info(f"Sentry initialized (environment: {environment})") + self._initialized = True + return True + + 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: + scope.set_tag("source", "fastapi_middleware") + scope.set_tag("http.method", request.method) + scope.set_tag("http.path", request.url.path) + + 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..7f1f27170 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(): @@ -592,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)}",