From f3591d889ea09a86883ed0a2e5852ba82d17df81 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 d08d31493..f6c1aca02 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 1e605d0aa..a1c9b5ea0 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 209b6d2c3a07bc3298f760a98b3279001cecf863 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 5173cb612..e36767ba7 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 c7a082a5b584757634f1d162368273cbece943d3 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 68dbb7952..1f340b3b8 100644 --- a/docs/docs/plugins/execution-context.md +++ b/docs/docs/plugins/execution-context.md @@ -42,6 +42,16 @@ Exported from `@databricks/appkit`: - `getWarehouseId()`: `Promise` (from `DATABRICKS_WAREHOUSE_ID` or auto-selected in dev) - `getWorkspaceId()`: `Promise` (from `DATABRICKS_WORKSPACE_ID` or fetched) +## 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 f6c1aca02..da792317b 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 e36767ba7..75d994d88 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 a1c9b5ea0..bce1fc012 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 1df5cba08086757b9110e7deef76fe69a7aab2b8 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 b7ff7c76d..dc80f936f 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -81,6 +81,7 @@ "zod": "4.3.6" }, "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 440579d79..0a08bdb51 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 fc6e389ef..684f6e2e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,6 +330,9 @@ importers: specifier: 4.3.6 version: 4.3.6 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 @@ -2790,6 +2793,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} @@ -15118,6 +15127,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 f437d5c7785dbea9e41b2d5930b556f7a3ad021d 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 da792317b..8829da38a 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 bce1fc012..d4c78ebf6 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 5fd55ebe60fbef07b7bb16b7d99b446b6f59be1f 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 7b7cf5684..297ecde09 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 1f340b3b8..98d2815bd 100644 --- a/docs/docs/plugins/execution-context.md +++ b/docs/docs/plugins/execution-context.md @@ -52,6 +52,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 ca6e62efa0d217d49f8a93d868bf02e54c05ac2c 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 a9c688dac..d591e32f0 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 1ca6c254a..c07c114cf 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 c6422c9ed7edfe9eb9e362d5f362fac19e5e2937 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 21 Apr 2026 16:51:00 +0200 Subject: [PATCH 8/8] fix: re-add isInUserContext() removed by files policy refactor The function was removed on main in feat(files): per-volume in-app policy enforcement (#197) since the files plugin no longer needed it. The telemetry interceptor needs it to set execution.context span attributes. Signed-off-by: Pawel Kosiec --- .../appkit/src/context/execution-context.ts | 8 ++++++++ .../src/plugin/interceptors/telemetry.ts | 18 +++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/context/execution-context.ts b/packages/appkit/src/context/execution-context.ts index dbc4788e1..d707f52de 100644 --- a/packages/appkit/src/context/execution-context.ts +++ b/packages/appkit/src/context/execution-context.ts @@ -81,3 +81,11 @@ export function getWarehouseId(): Promise { export function getWorkspaceId(): Promise { return getExecutionContext().workspaceId; } + +/** + * Check if currently running in a user context. + */ +export function isInUserContext(): boolean { + const ctx = executionContextStorage.getStore(); + return ctx !== undefined; +} diff --git a/packages/appkit/src/plugin/interceptors/telemetry.ts b/packages/appkit/src/plugin/interceptors/telemetry.ts index 8829da38a..2dd2d6a15 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 });