From f442996d54838358a14c0e92704ff5c02002caba Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 20 Apr 2026 14:14:03 +0200 Subject: [PATCH 1/8] feat: add execution context attributes to telemetry spans Add `execution.context` and `caller.id` span attributes to the telemetry interceptor, allowing traces to distinguish OBO (user) from service principal code paths. Signed-off-by: Pawel Kosiec --- .../src/plugin/interceptors/telemetry.ts | 7 ++++ .../tests/telemetry-interceptor.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/appkit/src/plugin/interceptors/telemetry.ts b/packages/appkit/src/plugin/interceptors/telemetry.ts index d08d3149..f6c1aca0 100644 --- a/packages/appkit/src/plugin/interceptors/telemetry.ts +++ b/packages/appkit/src/plugin/interceptors/telemetry.ts @@ -1,4 +1,5 @@ import type { TelemetryConfig } from "shared"; +import { isInUserContext } from "../../context/execution-context"; import type { ITelemetry, Span } from "../../telemetry"; import { SpanStatusCode } from "../../telemetry"; import type { ExecutionInterceptor, InterceptorContext } from "./types"; @@ -24,6 +25,12 @@ export class TelemetryInterceptor implements ExecutionInterceptor { spanName, { attributes: this.config?.attributes }, async (span: Span) => { + span.setAttribute( + "execution.context", + isInUserContext() ? "user" : "service", + ); + span.setAttribute("caller.id", context.userKey); + let abortHandler: (() => void) | undefined; let isAborted = false; diff --git a/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts b/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts index 1e605d0a..a1c9b5ea 100644 --- a/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts +++ b/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts @@ -1,6 +1,7 @@ import { type Span, SpanStatusCode } from "@opentelemetry/api"; import type { TelemetryConfig } from "shared"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import * as executionContext from "../../context/execution-context"; import { TelemetryInterceptor } from "../../plugin/interceptors/telemetry"; import type { InterceptorContext } from "../../plugin/interceptors/types"; import type { ITelemetry } from "../types"; @@ -131,4 +132,36 @@ describe("TelemetryInterceptor", () => { // Verify end was called despite the error expect(mockSpan.end).toHaveBeenCalledTimes(1); }); + + test("should set execution context as 'service' when not in user context", async () => { + vi.spyOn(executionContext, "isInUserContext").mockReturnValue(false); + const interceptor = new TelemetryInterceptor(mockTelemetry); + const fn = vi.fn().mockResolvedValue("result"); + + await interceptor.intercept(fn, context); + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + "execution.context", + "service", + ); + expect(mockSpan.setAttribute).toHaveBeenCalledWith("caller.id", "test"); + }); + + test("should set execution context as 'user' when in user context", async () => { + vi.spyOn(executionContext, "isInUserContext").mockReturnValue(true); + const interceptor = new TelemetryInterceptor(mockTelemetry); + const fn = vi.fn().mockResolvedValue("result"); + const userContext: InterceptorContext = { + metadata: new Map(), + userKey: "user-123", + }; + + await interceptor.intercept(fn, userContext); + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + "execution.context", + "user", + ); + expect(mockSpan.setAttribute).toHaveBeenCalledWith("caller.id", "user-123"); + }); }); From 4839c1f43814fd370fc3a8a6c97cad686dc2ba59 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 20 Apr 2026 17:20:42 +0200 Subject: [PATCH 2/8] fix: preserve OTel context across async generator boundary in executeStream The TelemetryInterceptor spans were orphaned because OTel lost the parent HTTP span context when crossing into the async generator. Capture context.active() before the generator and restore it with context.with() inside, so plugin.execute spans appear as children of the HTTP request trace. Signed-off-by: Pawel Kosiec --- packages/appkit/src/plugin/plugin.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 5173cb61..e36767ba 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -1,3 +1,4 @@ +import { context as otelContext } from "@opentelemetry/api"; import type express from "express"; import type { BasePlugin, @@ -409,6 +410,9 @@ export abstract class Plugin< const effectiveUserKey = userKey ?? getCurrentUserId(); const self = this; + // capture the active OTel context (HTTP span) before entering the async generator, + // where it would otherwise be lost across the async boundary + const parentOtelContext = otelContext.active(); // wrapper function to ensure it returns a generator const asyncWrapperFn = async function* (streamSignal?: AbortSignal) { @@ -428,11 +432,14 @@ export abstract class Plugin< return result; }; - // execute the function with interceptors - const result = await self._executeWithInterceptors( - wrappedFn as (signal?: AbortSignal) => Promise, - interceptors, - context, + // execute the function with interceptors, restoring the parent OTel context + // so telemetry spans are linked as children of the HTTP request span + const result = await otelContext.with(parentOtelContext, () => + self._executeWithInterceptors( + wrappedFn as (signal?: AbortSignal) => Promise, + interceptors, + context, + ), ); // check if result is a generator From f476b7f07d4b403b262e5e20d51473bfe99fd7b0 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 10:50:31 +0200 Subject: [PATCH 3/8] feat: add execution.obo_dev_fallback span attribute for OBO dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When asUser() is called in dev mode without x-forwarded-access-token, the telemetry span now includes execution.obo_dev_fallback: true to distinguish intended OBO calls from regular service principal calls. Uses OTel context key + thin proxy pattern to carry the flag without mutable state — scoped automatically per execution and concurrent-safe. Also documents telemetry span attributes in execution-context.md. Signed-off-by: Pawel Kosiec --- docs/docs/plugins/execution-context.md | 12 ++++++- .../src/plugin/interceptors/telemetry.ts | 4 +++ packages/appkit/src/plugin/plugin.ts | 34 +++++++++++++++++-- .../tests/telemetry-interceptor.test.ts | 33 ++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/docs/plugins/execution-context.md b/docs/docs/plugins/execution-context.md index 6c7f8960..1e3ebf8f 100644 --- a/docs/docs/plugins/execution-context.md +++ b/docs/docs/plugins/execution-context.md @@ -43,6 +43,16 @@ Exported from `@databricks/appkit`: - `getWorkspaceId()`: `Promise` (from `DATABRICKS_WORKSPACE_ID` or fetched) - `isInUserContext()`: Returns `true` if currently executing in user context +## Telemetry span attributes + +The `plugin.execute` span created by the execution interceptor chain includes these attributes: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `execution.context` | `"user"` \| `"service"` | Whether the operation runs as a user (OBO) or service principal | +| `caller.id` | `string` | The user ID (OBO) or service principal ID | +| `execution.obo_dev_fallback` | `boolean` | Set to `true` when an OBO call falls back to service principal in development mode | + ## Development mode behavior -In local development (`NODE_ENV=development`), if `asUser(req)` is called without a user token, it logs a warning and skips user impersonation — the operation runs with the default credentials configured for the app instead. +In local development (`NODE_ENV=development`), if `asUser(req)` is called without a user token, it logs a warning and skips user impersonation — the operation runs with the default credentials configured for the app instead. The telemetry span will show `execution.context: "service"` with `execution.obo_dev_fallback: true` to distinguish these from regular service principal calls. diff --git a/packages/appkit/src/plugin/interceptors/telemetry.ts b/packages/appkit/src/plugin/interceptors/telemetry.ts index f6c1aca0..da792317 100644 --- a/packages/appkit/src/plugin/interceptors/telemetry.ts +++ b/packages/appkit/src/plugin/interceptors/telemetry.ts @@ -2,6 +2,7 @@ import type { TelemetryConfig } from "shared"; import { isInUserContext } from "../../context/execution-context"; import type { ITelemetry, Span } from "../../telemetry"; import { SpanStatusCode } from "../../telemetry"; +import { isDevOboFallback } from "../plugin"; import type { ExecutionInterceptor, InterceptorContext } from "./types"; export class TelemetryInterceptor implements ExecutionInterceptor { @@ -30,6 +31,9 @@ export class TelemetryInterceptor implements ExecutionInterceptor { isInUserContext() ? "user" : "service", ); span.setAttribute("caller.id", context.userKey); + if (isDevOboFallback()) { + span.setAttribute("execution.obo_dev_fallback", true); + } let abortHandler: (() => void) | undefined; let isAborted = false; diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index e36767ba..75d994d8 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -1,4 +1,4 @@ -import { context as otelContext } from "@opentelemetry/api"; +import { createContextKey, context as otelContext } from "@opentelemetry/api"; import type express from "express"; import type { BasePlugin, @@ -42,6 +42,20 @@ import type { const logger = createLogger("plugin"); +/** + * OTel context key for marking OBO dev mode fallback. + * Set when asUser() is called in development mode without a user token. + */ +const DEV_OBO_FALLBACK_KEY = createContextKey("appkit.devOboFallback"); + +/** + * Returns true if the current execution is an OBO dev mode fallback + * (asUser() was called but fell back to service principal due to missing token). + */ +export function isDevOboFallback(): boolean { + return otelContext.active().getValue(DEV_OBO_FALLBACK_KEY) === true; +} + /** * Narrow an unknown thrown value to an Error that carries a numeric * `statusCode` property (e.g. `ApiError` from `@databricks/sdk-experimental`). @@ -339,7 +353,23 @@ export abstract class Plugin< "asUser() called without user token in development mode. Skipping user impersonation.", ); - return this; + // Return a proxy that marks execution as OBO dev fallback via OTel context, + // so telemetry spans can distinguish intended OBO calls from regular SP calls + return new Proxy(this, { + get: (target, prop, receiver) => { + const value = Reflect.get(target, prop, receiver); + if (typeof value !== "function") return value; + if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop)) + return value; + + return (...args: unknown[]) => { + const ctx = otelContext + .active() + .setValue(DEV_OBO_FALLBACK_KEY, true); + return otelContext.with(ctx, () => value.apply(target, args)); + }; + }, + }) as this; } if (!token) { diff --git a/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts b/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts index a1c9b5ea..bce1fc01 100644 --- a/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts +++ b/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import * as executionContext from "../../context/execution-context"; import { TelemetryInterceptor } from "../../plugin/interceptors/telemetry"; import type { InterceptorContext } from "../../plugin/interceptors/types"; +import * as pluginModule from "../../plugin/plugin"; import type { ITelemetry } from "../types"; describe("TelemetryInterceptor", () => { @@ -164,4 +165,36 @@ describe("TelemetryInterceptor", () => { ); expect(mockSpan.setAttribute).toHaveBeenCalledWith("caller.id", "user-123"); }); + + test("should set execution.obo_dev_fallback when in dev OBO fallback", async () => { + vi.spyOn(executionContext, "isInUserContext").mockReturnValue(false); + vi.spyOn(pluginModule, "isDevOboFallback").mockReturnValue(true); + const interceptor = new TelemetryInterceptor(mockTelemetry); + const fn = vi.fn().mockResolvedValue("result"); + + await interceptor.intercept(fn, context); + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + "execution.context", + "service", + ); + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + "execution.obo_dev_fallback", + true, + ); + }); + + test("should not set execution.obo_dev_fallback when not in dev fallback", async () => { + vi.spyOn(executionContext, "isInUserContext").mockReturnValue(false); + vi.spyOn(pluginModule, "isDevOboFallback").mockReturnValue(false); + const interceptor = new TelemetryInterceptor(mockTelemetry); + const fn = vi.fn().mockResolvedValue("result"); + + await interceptor.intercept(fn, context); + + expect(mockSpan.setAttribute).not.toHaveBeenCalledWith( + "execution.obo_dev_fallback", + expect.anything(), + ); + }); }); From 6d5f5770927a2781572fdae0dea27c069c5503ae Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 12:44:05 +0200 Subject: [PATCH 4/8] test: add coverage for asUser() dev fallback proxy and OTel context preservation Add tests for previously uncovered behaviors: - asUser() dev fallback Proxy wraps methods correctly and sets isDevOboFallback context - EXCLUDED_FROM_PROXY methods bypass OBO fallback wrapping - executeStream preserves parent OTel context across async generator boundary - isDevOboFallback() returns false outside proxy context Signed-off-by: Pawel Kosiec --- packages/appkit/package.json | 1 + .../appkit/src/plugin/tests/plugin.test.ts | 193 +++++++++++++++++- pnpm-lock.yaml | 13 ++ 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 146be5a9..a5039c3c 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -80,6 +80,7 @@ "ws": "8.18.3" }, "devDependencies": { + "@opentelemetry/context-async-hooks": "2.6.1", "@types/express": "4.17.25", "@types/json-schema": "7.0.15", "@types/pg": "8.16.0", diff --git a/packages/appkit/src/plugin/tests/plugin.test.ts b/packages/appkit/src/plugin/tests/plugin.test.ts index 440579d7..0a08bdb5 100644 --- a/packages/appkit/src/plugin/tests/plugin.test.ts +++ b/packages/appkit/src/plugin/tests/plugin.test.ts @@ -1,3 +1,9 @@ +import { + type ContextManager, + createContextKey, + context as otelContext, +} from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; import { createMockTelemetry, mockServiceContext } from "@tools/test-helpers"; import type express from "express"; import type { @@ -5,7 +11,16 @@ import type { IAppResponse, PluginExecuteConfig, } from "shared"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; import { AppManager } from "../../app"; import { CacheManager } from "../../cache"; import { ServiceContext } from "../../context/service-context"; @@ -20,7 +35,7 @@ import { StreamManager } from "../../stream"; import type { ITelemetry, TelemetryProvider } from "../../telemetry"; import { TelemetryManager } from "../../telemetry"; import type { InterceptorContext } from "../interceptors/types"; -import { Plugin } from "../plugin"; +import { isDevOboFallback, Plugin } from "../plugin"; const { MockApiError } = vi.hoisted(() => { class MockApiError extends Error { @@ -148,6 +163,20 @@ class PluginWithRoutes extends TestPlugin { } } +class OboTestPlugin extends Plugin { + lastOboFallbackValue: boolean | undefined; + + async captureOboFallback(): Promise { + this.lastOboFallbackValue = isDevOboFallback(); + return "captured"; + } + + syncCapture(): string { + this.lastOboFallbackValue = isDevOboFallback(); + return "sync-captured"; + } +} + describe("Plugin", () => { let mockTelemetry: ITelemetry; let mockCache: CacheManager; @@ -916,4 +945,164 @@ describe("Plugin", () => { expect(result).toEqual({ ok: true, data: "integration-result" }); }); }); + + describe("asUser() dev fallback", () => { + let originalNodeEnv: string | undefined; + let contextManager: ContextManager; + + beforeAll(() => { + otelContext.disable(); + contextManager = new AsyncLocalStorageContextManager().enable(); + otelContext.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + otelContext.disable(); + }); + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + vi.useRealTimers(); + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + function createMockReqWithoutToken(): express.Request { + return { + header: vi.fn().mockReturnValue(undefined), + } as unknown as express.Request; + } + + test("should return a Proxy (different reference) in dev mode without token", () => { + const plugin = new TestPlugin(config); + const proxied = plugin.asUser(createMockReqWithoutToken()); + + expect(proxied).not.toBe(plugin); + expect(proxied).toBeInstanceOf(TestPlugin); + }); + + test("should pass through non-function properties unchanged", () => { + const plugin = new TestPlugin(config); + const proxied = plugin.asUser(createMockReqWithoutToken()); + + expect(proxied.name).toBe(plugin.name); + }); + + test("should preserve return values from proxied async methods", async () => { + const plugin = new TestPlugin(config); + const proxied = plugin.asUser(createMockReqWithoutToken()); + + const result = await proxied.customMethod("value"); + expect(result).toBe("processed-value"); + }); + + test("should preserve return values from proxied sync methods", () => { + const plugin = new TestPlugin(config); + const proxied = plugin.asUser(createMockReqWithoutToken()); + + const result = proxied.syncMethod("value"); + expect(result).toBe("sync-value"); + }); + + test("should set isDevOboFallback() to true inside proxied method", async () => { + const plugin = new OboTestPlugin(config); + const proxied = plugin.asUser(createMockReqWithoutToken()); + + await proxied.captureOboFallback(); + + expect(plugin.lastOboFallbackValue).toBe(true); + }); + + test("should set isDevOboFallback() to true inside proxied sync method", () => { + const plugin = new OboTestPlugin(config); + const proxied = plugin.asUser(createMockReqWithoutToken()); + + proxied.syncCapture(); + + expect(plugin.lastOboFallbackValue).toBe(true); + }); + + test("should not set OBO fallback for excluded methods (setup)", async () => { + const plugin = new OboTestPlugin(config); + // Override setup to capture OBO fallback + plugin.setup = async () => { + plugin.lastOboFallbackValue = isDevOboFallback(); + }; + + const proxied = plugin.asUser(createMockReqWithoutToken()); + await proxied.setup(); + + expect(plugin.lastOboFallbackValue).toBe(false); + }); + + test("isDevOboFallback() should return false outside proxy context", () => { + expect(isDevOboFallback()).toBe(false); + }); + }); + + describe("executeStream OTel context preservation", () => { + let contextManager: ContextManager; + + beforeAll(() => { + otelContext.disable(); + contextManager = new AsyncLocalStorageContextManager().enable(); + otelContext.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + otelContext.disable(); + }); + + beforeEach(() => { + vi.useRealTimers(); + }); + + test("should preserve parent OTel context inside async generator", async () => { + const plugin = new TestPlugin(config); + const mockResponse = {} as IAppResponse; + + const TEST_KEY = createContextKey("test.parent.context"); + const parentCtx = otelContext.active().setValue(TEST_KEY, "parent-value"); + + let capturedContextValue: unknown; + + const mockFn = vi.fn().mockImplementation(async () => { + capturedContextValue = otelContext.active().getValue(TEST_KEY); + return "stream-result"; + }); + + // Capture the generator function passed to streamManager.stream + let capturedGeneratorFn: any; + vi.mocked(mockStreamManager.stream).mockImplementation( + async (_res, genFn) => { + capturedGeneratorFn = genFn; + }, + ); + + // Execute within the parent context + await otelContext.with(parentCtx, () => + (plugin as any).executeStream(mockResponse, mockFn, { + default: {}, + stream: {}, + }), + ); + + // Invoke the captured generator OUTSIDE the parent context scope + // The generator should restore parentOtelContext internally + const gen = capturedGeneratorFn(); + await gen.next(); + + expect(capturedContextValue).toBe("parent-value"); + }); + + test("should not have parent context without the fix (baseline)", async () => { + const TEST_KEY = createContextKey("test.baseline.context"); + + // Outside any context, the value should not exist + expect(otelContext.active().getValue(TEST_KEY)).toBeUndefined(); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ca11b81..24b4d9cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,9 @@ importers: specifier: 8.18.3 version: 8.18.3(bufferutil@4.0.9) devDependencies: + '@opentelemetry/context-async-hooks': + specifier: 2.6.1 + version: 2.6.1(@opentelemetry/api@1.9.0) '@types/express': specifier: 4.17.25 version: 4.17.25 @@ -2787,6 +2790,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/context-async-hooks@2.6.1': + resolution: {integrity: sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.2.0': resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -15111,6 +15120,10 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 From 29327863f3a94ebb879186495078d3dfa8d57bd4 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 13:58:21 +0200 Subject: [PATCH 5/8] fix: use getCurrentUserId() for caller.id span attribute The caller.id attribute was using context.userKey which is a cache key, not always the real user ID. The analytics plugin passes "global" for SP queries, so traces showed caller.id: "global" instead of the actual service principal ID. Now uses getCurrentUserId() which always returns the real identity. Signed-off-by: Pawel Kosiec --- packages/appkit/src/plugin/interceptors/telemetry.ts | 7 +++++-- .../telemetry/tests/telemetry-interceptor.test.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/appkit/src/plugin/interceptors/telemetry.ts b/packages/appkit/src/plugin/interceptors/telemetry.ts index da792317..8829da38 100644 --- a/packages/appkit/src/plugin/interceptors/telemetry.ts +++ b/packages/appkit/src/plugin/interceptors/telemetry.ts @@ -1,5 +1,8 @@ import type { TelemetryConfig } from "shared"; -import { isInUserContext } from "../../context/execution-context"; +import { + getCurrentUserId, + isInUserContext, +} from "../../context/execution-context"; import type { ITelemetry, Span } from "../../telemetry"; import { SpanStatusCode } from "../../telemetry"; import { isDevOboFallback } from "../plugin"; @@ -30,7 +33,7 @@ export class TelemetryInterceptor implements ExecutionInterceptor { "execution.context", isInUserContext() ? "user" : "service", ); - span.setAttribute("caller.id", context.userKey); + span.setAttribute("caller.id", getCurrentUserId()); if (isDevOboFallback()) { span.setAttribute("execution.obo_dev_fallback", true); } diff --git a/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts b/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts index bce1fc01..d4c78ebf 100644 --- a/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts +++ b/packages/appkit/src/telemetry/tests/telemetry-interceptor.test.ts @@ -38,6 +38,8 @@ describe("TelemetryInterceptor", () => { metadata: new Map(), userKey: "test", }; + + vi.spyOn(executionContext, "getCurrentUserId").mockReturnValue("test-user"); }); test("should execute function and set span status to OK on success", async () => { @@ -136,6 +138,7 @@ describe("TelemetryInterceptor", () => { test("should set execution context as 'service' when not in user context", async () => { vi.spyOn(executionContext, "isInUserContext").mockReturnValue(false); + vi.spyOn(executionContext, "getCurrentUserId").mockReturnValue("sp-123"); const interceptor = new TelemetryInterceptor(mockTelemetry); const fn = vi.fn().mockResolvedValue("result"); @@ -145,19 +148,16 @@ describe("TelemetryInterceptor", () => { "execution.context", "service", ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith("caller.id", "test"); + expect(mockSpan.setAttribute).toHaveBeenCalledWith("caller.id", "sp-123"); }); test("should set execution context as 'user' when in user context", async () => { vi.spyOn(executionContext, "isInUserContext").mockReturnValue(true); + vi.spyOn(executionContext, "getCurrentUserId").mockReturnValue("user-123"); const interceptor = new TelemetryInterceptor(mockTelemetry); const fn = vi.fn().mockResolvedValue("result"); - const userContext: InterceptorContext = { - metadata: new Map(), - userKey: "user-123", - }; - await interceptor.intercept(fn, userContext); + await interceptor.intercept(fn, context); expect(mockSpan.setAttribute).toHaveBeenCalledWith( "execution.context", From f7ba8dff761accc2bb3056bd5f46f5e921e59b29 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 15:21:41 +0200 Subject: [PATCH 6/8] docs: document telemetry span attributes and interceptor chain requirement Add telemetry span attributes table to execution-context.md and note that execute()/executeStream() is required for automatic instrumentation. Update custom-plugins.md to link telemetry attributes from the execution interceptors bullet. Signed-off-by: Pawel Kosiec --- docs/docs/plugins/custom-plugins.md | 2 +- docs/docs/plugins/execution-context.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/docs/plugins/custom-plugins.md b/docs/docs/plugins/custom-plugins.md index 7b7cf568..297ecde0 100644 --- a/docs/docs/plugins/custom-plugins.md +++ b/docs/docs/plugins/custom-plugins.md @@ -130,7 +130,7 @@ This pattern allows: - **Shared services**: - **Cache management**: Access the cache service via `this.cache`. See [`CacheConfig`](../api/appkit/Interface.CacheConfig.md) for configuration. - **Telemetry**: Instrument your plugin with traces and metrics via `this.telemetry`. See [`ITelemetry`](../api/appkit/Interface.ITelemetry.md). -- **Execution interceptors**: Use `execute()` and `executeStream()` with [`StreamExecutionSettings`](../api/appkit/Interface.StreamExecutionSettings.md) +- **Execution interceptors**: Use `execute()` and `executeStream()` with [`StreamExecutionSettings`](../api/appkit/Interface.StreamExecutionSettings.md) for automatic caching, retry, timeout, and [telemetry span attributes](./execution-context.md#telemetry-span-attributes) (`execution.context`, `caller.id`) **Consuming your plugin programmatically** diff --git a/docs/docs/plugins/execution-context.md b/docs/docs/plugins/execution-context.md index 1e3ebf8f..8d280e71 100644 --- a/docs/docs/plugins/execution-context.md +++ b/docs/docs/plugins/execution-context.md @@ -53,6 +53,8 @@ The `plugin.execute` span created by the execution interceptor chain includes th | `caller.id` | `string` | The user ID (OBO) or service principal ID | | `execution.obo_dev_fallback` | `boolean` | Set to `true` when an OBO call falls back to service principal in development mode | +These attributes are automatically added when your plugin uses `execute()` or `executeStream()`. All built-in plugins use these methods for their OBO operations. Custom plugins should do the same to get automatic telemetry instrumentation. + ## Development mode behavior In local development (`NODE_ENV=development`), if `asUser(req)` is called without a user token, it logs a warning and skips user impersonation — the operation runs with the default credentials configured for the app instead. The telemetry span will show `execution.context: "service"` with `execution.obo_dev_fallback: true` to distinguish these from regular service principal calls. From 97aba493715904ada6270a55c49499c5123064d2 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 16:13:12 +0200 Subject: [PATCH 7/8] feat: add db.user to lakebase spans and clarify arrow-result route Add db.user attribute to lakebase.query telemetry spans so traces show which PostgreSQL role executed the query. Also add a comment clarifying that the arrow-result route intentionally bypasses the interceptor chain (it's a data download, not a query execution). Signed-off-by: Pawel Kosiec --- packages/appkit/src/plugins/analytics/analytics.ts | 5 ++++- packages/lakebase/src/pool.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index a9c688da..d591e32f 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -45,7 +45,10 @@ export class AnalyticsPlugin extends Plugin { } injectRoutes(router: IAppRouter) { - // Service principal endpoints + // Arrow data downloads always run as service principal and bypass the + // interceptor chain (execute/executeStream). The original query execution + // handles OBO via executeStream(); this endpoint fetches pre-computed + // results by job ID. this.route(router, { name: "arrow", method: "get", diff --git a/packages/lakebase/src/pool.ts b/packages/lakebase/src/pool.ts index 1ca6c254..c07c114c 100644 --- a/packages/lakebase/src/pool.ts +++ b/packages/lakebase/src/pool.ts @@ -92,6 +92,7 @@ export function createLakebasePool( kind: SpanKind.CLIENT, attributes: { "db.system": "lakebase", + "db.user": poolConfig.user ?? "unknown", "db.statement": sql ? sql.substring(0, 500) : "unknown", }, }, From 99e3aef9024c105d8f1dbc234648f47582bef809 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 16:51:00 +0200 Subject: [PATCH 8/8] fix: move span attribute setup inside try block to prevent span leaks If getCurrentUserId() or isInUserContext() threw before this change, span.end() was never called because the calls were outside the try/finally block. Now all setAttribute calls are inside the try block, so the finally block guarantees span cleanup on any error. Signed-off-by: Pawel Kosiec --- .../src/plugin/interceptors/telemetry.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/plugin/interceptors/telemetry.ts b/packages/appkit/src/plugin/interceptors/telemetry.ts index 8829da38..2dd2d6a1 100644 --- a/packages/appkit/src/plugin/interceptors/telemetry.ts +++ b/packages/appkit/src/plugin/interceptors/telemetry.ts @@ -29,15 +29,6 @@ export class TelemetryInterceptor implements ExecutionInterceptor { spanName, { attributes: this.config?.attributes }, async (span: Span) => { - span.setAttribute( - "execution.context", - isInUserContext() ? "user" : "service", - ); - span.setAttribute("caller.id", getCurrentUserId()); - if (isDevOboFallback()) { - span.setAttribute("execution.obo_dev_fallback", true); - } - let abortHandler: (() => void) | undefined; let isAborted = false; @@ -59,6 +50,15 @@ export class TelemetryInterceptor implements ExecutionInterceptor { } try { + span.setAttribute( + "execution.context", + isInUserContext() ? "user" : "service", + ); + span.setAttribute("caller.id", getCurrentUserId()); + if (isDevOboFallback()) { + span.setAttribute("execution.obo_dev_fallback", true); + } + const result = await fn(); if (!isAborted) { span.setStatus({ code: SpanStatusCode.OK });