@databricks/appkit 0.75.1 → 0.76.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cache/index.d.ts +10 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +13 -0
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/commands/codemod/on-plugins-ready.js +2 -1
- package/dist/cli/commands/codemod/on-plugins-ready.js.map +1 -1
- package/dist/cli/commands/lint.js +2 -1
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/create.js +1 -1
- package/dist/cli/commands/plugin/create/create.js.map +1 -1
- package/dist/cli/commands/plugin/create/prompt-resource.js +1 -1
- package/dist/cli/commands/plugin/create/prompt-resource.js.map +1 -1
- package/dist/cli/commands/plugin/sync/sync.js +2 -1
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/registry/env-writer.js +4 -1
- package/dist/cli/commands/registry/env-writer.js.map +1 -1
- package/dist/connectors/sql-warehouse/client.js +13 -1
- package/dist/connectors/sql-warehouse/client.js.map +1 -1
- package/dist/core/appkit.d.ts +1 -1
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +26 -5
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/lifecycle-manager.js +47 -27
- package/dist/core/lifecycle-manager.js.map +1 -1
- package/dist/evals/judge.d.ts.map +1 -1
- package/dist/evals/judge.js +8 -2
- package/dist/evals/judge.js.map +1 -1
- package/dist/plugins/agents/mlflow.js +6 -1
- package/dist/plugins/agents/mlflow.js.map +1 -1
- package/dist/telemetry/telemetry-manager.js +17 -0
- package/dist/telemetry/telemetry-manager.js.map +1 -1
- package/dist/testing/create-test-app.d.ts +111 -0
- package/dist/testing/create-test-app.d.ts.map +1 -0
- package/dist/testing/create-test-app.js +187 -0
- package/dist/testing/create-test-app.js.map +1 -0
- package/dist/testing/create-test-plugin.d.ts +17 -0
- package/dist/testing/create-test-plugin.d.ts.map +1 -0
- package/dist/testing/create-test-plugin.js +22 -0
- package/dist/testing/create-test-plugin.js.map +1 -0
- package/dist/testing/fixtures.d.ts +51 -35
- package/dist/testing/fixtures.d.ts.map +1 -1
- package/dist/testing/fixtures.js +129 -54
- package/dist/testing/fixtures.js.map +1 -1
- package/dist/testing/index.d.ts +8 -3
- package/dist/testing/index.js +7 -2
- package/dist/testing/mock-workspace-client.d.ts +47 -0
- package/dist/testing/mock-workspace-client.d.ts.map +1 -0
- package/dist/testing/mock-workspace-client.js +192 -0
- package/dist/testing/mock-workspace-client.js.map +1 -0
- package/dist/testing/reset-singletons.js +39 -0
- package/dist/testing/reset-singletons.js.map +1 -0
- package/dist/testing/test-app.d.ts +54 -0
- package/dist/testing/test-app.d.ts.map +1 -0
- package/dist/testing/test-app.js +55 -0
- package/dist/testing/test-app.js.map +1 -0
- package/dist/testing/test-cache.d.ts +60 -0
- package/dist/testing/test-cache.d.ts.map +1 -0
- package/dist/testing/test-cache.js +65 -0
- package/dist/testing/test-cache.js.map +1 -0
- package/dist/testing/test-plugin-context.d.ts +43 -2
- package/dist/testing/test-plugin-context.d.ts.map +1 -1
- package/dist/testing/test-plugin-context.js +47 -2
- package/dist/testing/test-plugin-context.js.map +1 -1
- package/docs/api/appkit/Function.createApp.md +9 -9
- package/docs/plugins/agents.md +3 -1
- package/docs/plugins/testing.md +440 -14
- package/llms.txt +1 -1
- package/package.json +11 -3
- package/sbom.cdx.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle-manager.js","names":[],"sources":["../../src/core/lifecycle-manager.ts"],"sourcesContent":["import type { BasePlugin } from \"shared\";\n\nimport { CacheManager } from \"../cache\";\nimport { TelemetryReporter } from \"../internal-telemetry\";\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryManager } from \"../telemetry\";\nimport type { PluginContext } from \"./plugin-context\";\n\nconst logger = createLogger(\"lifecycle\");\n\n/**\n * Owns the process's graceful-shutdown sequence.\n *\n * Created by AppKit core once every plugin has started. It is the single\n * owner of the SIGTERM/SIGINT handlers and of `process.exit`, mirroring the\n * core-owned startup in `AppKit._createApp`: core initializes telemetry,\n * cache, and the internal-telemetry reporter, and core tears them all down\n * here. Plugins participate through the generic hooks\n * (`abortActiveOperations()`, `shutdown()`, and `onLifecycle(\"shutdown\")`) —\n * they do not touch process signals or the core singletons themselves.\n */\nexport class LifecycleManager {\n /**\n * Overall graceful-shutdown budget before the process is force-exited.\n *\n * Budget arithmetic: plugin `shutdown()` hooks run concurrently and are\n * bounded by {@link PLUGIN_SHUTDOWN_TIMEOUT_MS} (10s); the lifecycle emit\n * is bounded by {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s); the cache storage\n * close and the telemetry flush run concurrently, each bounded by\n * {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s). Worst case is\n * 10s + 2s + max(2s, 2s) = 14s, leaving ~1s of margin for the remaining\n * steps (aborts) before this timer force-exits.\n */\n private static readonly SHUTDOWN_TIMEOUT_MS = 15_000;\n /**\n * Per-plugin budget for `shutdown()` hooks. Sized to cover the longest\n * built-in drain (the files plugin waits up to 10s for in-flight writes).\n */\n private static readonly PLUGIN_SHUTDOWN_TIMEOUT_MS = 10_000;\n /**\n * Budget for each non-plugin shutdown phase (the `\"shutdown\"` lifecycle\n * emit, the cache storage close, and the telemetry flush). Keeps the\n * worst-case total under {@link SHUTDOWN_TIMEOUT_MS} — see the arithmetic\n * there.\n */\n private static readonly PHASE_SHUTDOWN_TIMEOUT_MS = 2_000;\n\n /**\n * Guards against re-entrant shutdown (e.g. SIGTERM followed by SIGINT).\n * The flag set in `shutdown` must remain synchronous and first — any\n * `await` before it would open a window for a second signal to re-enter\n * the sequence.\n */\n private isShuttingDown = false;\n /**\n * Name of the shutdown phase currently in flight, so the force-exit log\n * can say where shutdown got stuck without extra bookkeeping.\n */\n private shutdownPhase = \"not started\";\n\n constructor(private readonly context: PluginContext) {}\n\n /**\n * Install the SIGTERM/SIGINT handlers that trigger {@link shutdown}.\n *\n * Uses `process.once` (not `on`) so a repeated signal cannot register the\n * handler twice; re-entrancy from a *different* signal is guarded by\n * `isShuttingDown` inside {@link shutdown}.\n */\n installSignalHandlers(): void {\n process.once(\"SIGTERM\", () => this.shutdown());\n process.once(\"SIGINT\", () => this.shutdown());\n }\n\n /**\n * Run the graceful-shutdown sequence and exit the process.\n *\n * Phases:\n * 1. stop the internal-telemetry reporter\n * 2. abort in-flight work on every plugin (cancellation only — teardown of\n * shared resources belongs in `shutdown()` so peers can still drain)\n * 3. run every plugin's `shutdown()` hook concurrently, each bounded\n * 4. emit the `\"shutdown\"` lifecycle event, bounded\n * 5. close the cache storage and flush telemetry concurrently, each bounded\n *\n * Exits 0 on completion (and on the force-exit backstop): a deliberate\n * shutdown is not a crash. Exit 1 is reserved for an unexpected error\n * thrown by the sequence itself.\n */\n async shutdown(): Promise<void> {\n // Must stay synchronous and first: any await before the flag is set\n // would let a second signal re-enter the shutdown sequence.\n if (this.isShuttingDown) return;\n this.isShuttingDown = true;\n\n logger.info(\"Starting graceful shutdown...\");\n\n let exitCode = 0;\n\n // Force exit once the overall budget is spent. Exit 0 is deliberate:\n // a force-timeout still happens on a routine deploy (deliberate\n // shutdown, not a crash), and orchestrators record nonzero exits on\n // deploys as crashes. The error log below is the stuck-shutdown\n // signal instead of the exit code.\n const forceExitTimer = setTimeout(() => {\n logger.error(\n \"Graceful shutdown did NOT complete within the %dms budget (phase in flight: %s); force-exiting with code 0.\",\n LifecycleManager.SHUTDOWN_TIMEOUT_MS,\n this.shutdownPhase,\n );\n process.exit(0);\n }, LifecycleManager.SHUTDOWN_TIMEOUT_MS);\n // unref so this backstop timer never by itself keeps the process alive.\n // Any real pending teardown (OTEL export timer, DB pool sockets, the\n // still-open HTTP listener) is a ref'd handle that holds the loop open\n // until this fires; if nothing is ref'd, there is nothing left to tear\n // down and exiting early is correct.\n forceExitTimer.unref();\n\n try {\n const plugins = Array.from(this.context.getPlugins().values());\n\n // 1. stop the internal-telemetry reporter (no-op if never started).\n this.shutdownPhase = \"stopping internal telemetry reporter\";\n TelemetryReporter.getInstance()?.stop();\n\n // 2. abort active operations from plugins (in-flight executions, SSE\n // streams). Cancellation only — resource teardown (e.g. the\n // lakebase pools, the server's socket close) belongs in plugin\n // shutdown() hooks / lifecycle subscribers so other plugins can\n // still drain state through them.\n this.shutdownPhase = \"aborting active operations\";\n for (const plugin of plugins) {\n if (plugin.abortActiveOperations) {\n try {\n plugin.abortActiveOperations();\n } catch (err) {\n logger.error(\n \"Error aborting operations for plugin %s: %O\",\n plugin.name,\n err,\n );\n }\n }\n }\n\n // 3. run every plugin's shutdown() hook concurrently, each bounded\n // by a per-plugin timeout so one hung plugin cannot stall exit.\n this.shutdownPhase = \"plugin shutdown() hooks\";\n await Promise.all(\n plugins\n .filter((plugin) => typeof plugin.shutdown === \"function\")\n .map((plugin) => this.runPluginShutdown(plugin)),\n );\n\n // 4. notify lifecycle subscribers, bounded so a slow subscriber\n // cannot eat the remaining budget. The server plugin closes its\n // remaining sockets here, after other plugins have drained.\n this.shutdownPhase = \"shutdown lifecycle emit\";\n try {\n await this.raceWithTimeout(\n this.context.emitLifecycle(\"shutdown\"),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"shutdown lifecycle emit\",\n );\n } catch (err) {\n logger.error(\"Error emitting shutdown lifecycle event: %O\", err);\n }\n\n // 5. close the cache manager's storage (drains the persistent\n // Lakebase pool; no-op for in-memory storage) and flush telemetry.\n // Runs after the lifecycle emit so subscribers can still read the\n // cache. The two are independent (the flush never touches the\n // cache), so they run concurrently — each bounded so a stuck pool\n // drain or stalled OTLP export cannot eat the remaining budget.\n this.shutdownPhase = \"cache storage close + telemetry flush\";\n await Promise.all([this.closeCacheStorage(), this.flushTelemetry()]);\n\n logger.info(\"Graceful shutdown complete\");\n } catch (err) {\n // Exit 1 is reserved for an unexpected error thrown by the sequence\n // itself; every per-phase failure above is already caught and logged.\n logger.error(\"Error during graceful shutdown: %O\", err);\n exitCode = 1;\n }\n\n clearTimeout(forceExitTimer);\n process.exit(exitCode);\n }\n\n /** Close the cache storage, bounded and error-isolated. */\n private async closeCacheStorage(): Promise<void> {\n let cache: CacheManager;\n try {\n cache = CacheManager.getInstanceSync();\n } catch {\n // Cache was never initialized — nothing to close.\n return;\n }\n try {\n await this.raceWithTimeout(\n cache.close(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"cache storage close\",\n );\n } catch (err) {\n logger.error(\"Error closing cache storage during shutdown: %O\", err);\n }\n }\n\n /** Flush and shut down the telemetry SDK, bounded and error-isolated. */\n private async flushTelemetry(): Promise<void> {\n try {\n await this.raceWithTimeout(\n TelemetryManager.getInstance().shutdown(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"telemetry flush\",\n );\n } catch (err) {\n logger.error(\"Error flushing telemetry during shutdown: %O\", err);\n }\n }\n\n /**\n * Run a single plugin's `shutdown()` hook bounded by\n * {@link LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS}. Errors and timeouts\n * are logged but never thrown so one misbehaving plugin cannot block\n * the rest of the shutdown sequence.\n */\n private async runPluginShutdown(plugin: BasePlugin): Promise<void> {\n try {\n await this.raceWithTimeout(\n plugin.shutdown?.(),\n LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS,\n \"shutdown()\",\n );\n } catch (err) {\n logger.error(\"Error shutting down plugin %s: %O\", plugin.name, err);\n }\n }\n\n /**\n * Race `work` against a timeout. Rejects with a labeled error when the\n * timeout wins. A no-op rejection handler is attached to the work promise\n * before racing so a branch that rejects after the timeout already won\n * does not surface as an unhandledRejection.\n */\n private async raceWithTimeout<T>(\n work: Promise<T> | T,\n timeoutMs: number,\n label: string,\n ): Promise<T> {\n const promise = Promise.resolve(work);\n promise.catch(() => {});\n let timer: NodeJS.Timeout | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(\n () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n timer.unref();\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"lifecycle-manager.js","names":[],"sources":["../../src/core/lifecycle-manager.ts"],"sourcesContent":["import type { BasePlugin } from \"shared\";\n\nimport { CacheManager } from \"../cache\";\nimport { TelemetryReporter } from \"../internal-telemetry\";\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryManager } from \"../telemetry\";\nimport type { PluginContext } from \"./plugin-context\";\n\nconst logger = createLogger(\"lifecycle\");\n\n/**\n * Owns the process's graceful-shutdown sequence.\n *\n * Created by AppKit core once every plugin has started. It is the single\n * owner of the SIGTERM/SIGINT handlers and of `process.exit`, mirroring the\n * core-owned startup in `AppKit._createApp`: core initializes telemetry,\n * cache, and the internal-telemetry reporter, and core tears them all down\n * here. Plugins participate through the generic hooks\n * (`abortActiveOperations()`, `shutdown()`, and `onLifecycle(\"shutdown\")`) —\n * they do not touch process signals or the core singletons themselves.\n */\nexport class LifecycleManager {\n /**\n * Overall graceful-shutdown budget before the process is force-exited.\n *\n * Budget arithmetic: plugin `shutdown()` hooks run concurrently and are\n * bounded by {@link PLUGIN_SHUTDOWN_TIMEOUT_MS} (10s); the lifecycle emit\n * is bounded by {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s); the cache storage\n * close and the telemetry flush run concurrently, each bounded by\n * {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s). Worst case is\n * 10s + 2s + max(2s, 2s) = 14s, leaving ~1s of margin for the remaining\n * steps (aborts) before this timer force-exits.\n */\n private static readonly SHUTDOWN_TIMEOUT_MS = 15_000;\n /**\n * Per-plugin budget for `shutdown()` hooks. Sized to cover the longest\n * built-in drain (the files plugin waits up to 10s for in-flight writes).\n */\n private static readonly PLUGIN_SHUTDOWN_TIMEOUT_MS = 10_000;\n /**\n * Budget for each non-plugin shutdown phase (the `\"shutdown\"` lifecycle\n * emit, the cache storage close, and the telemetry flush). Keeps the\n * worst-case total under {@link SHUTDOWN_TIMEOUT_MS} — see the arithmetic\n * there.\n */\n private static readonly PHASE_SHUTDOWN_TIMEOUT_MS = 2_000;\n\n /**\n * The in-flight teardown, memoized. A boolean guard would let a second caller\n * return while teardown was still running — fine for a signal, wrong for the\n * harness path (`{ exit: false }`), which must not resolve before resources\n * are released.\n */\n private teardown: Promise<number> | undefined;\n /** Reported by the force-exit log so a stuck shutdown names its phase. */\n private shutdownPhase = \"not started\";\n\n constructor(private readonly context: PluginContext) {}\n\n /**\n * Install the SIGTERM/SIGINT handlers that trigger {@link shutdown}. Never\n * removed: the signal path exits the process, and the harness opts out of\n * installing them, so nothing accumulates across boots.\n */\n installSignalHandlers(): void {\n process.once(\"SIGTERM\", () => void this.shutdown());\n process.once(\"SIGINT\", () => void this.shutdown());\n }\n\n /**\n * Run the graceful-shutdown sequence. Exits the process unless\n * `exit: false` — the flag the test harness passes so it can tear a booted\n * app down between tests without killing the vitest process.\n *\n * Phases:\n * 1. stop the internal-telemetry reporter\n * 2. abort in-flight work on every plugin (cancellation only — teardown of\n * shared resources belongs in `shutdown()` so peers can still drain)\n * 3. run every plugin's `shutdown()` hook concurrently, each bounded\n * 4. emit the `\"shutdown\"` lifecycle event, bounded\n * 5. close the cache storage and flush telemetry concurrently, each bounded\n *\n * Every phase is individually bounded, so the sequence always completes —\n * `{ exit: false }` therefore needs no outer timeout and an `afterEach`\n * cannot hang on it. A second call joins the first teardown.\n *\n * Exits 0 on completion (and on the force-exit backstop): a deliberate\n * shutdown is not a crash. Exit 1 is reserved for an unexpected error\n * thrown by the sequence itself.\n */\n async shutdown(options: { exit?: boolean } = {}): Promise<void> {\n const exit = options.exit ?? true;\n\n if (!exit) {\n // Harness path: no backstop, no process.exit. The phases are internally\n // bounded, and this is fully awaited before the harness drops the\n // singletons — so phase 5 always acts on this app's own cache/telemetry.\n await this.runPhasesOnce();\n return;\n }\n\n // Exit 0 on force-timeout: a stuck deploy shutdown is not a crash, and\n // orchestrators read nonzero deploy exits as one. The error log is the\n // signal instead. Belt-and-suspenders over the per-phase budgets.\n const forceExitTimer = setTimeout(() => {\n logger.error(\n \"Graceful shutdown did NOT complete within the %dms budget (phase in flight: %s); force-exiting with code 0.\",\n LifecycleManager.SHUTDOWN_TIMEOUT_MS,\n this.shutdownPhase,\n );\n process.exit(0);\n }, LifecycleManager.SHUTDOWN_TIMEOUT_MS);\n // unref'd so the backstop alone never holds the process open; real pending\n // teardown is ref'd and keeps the loop alive until this fires.\n forceExitTimer.unref();\n\n const exitCode = await this.runPhasesOnce();\n\n clearTimeout(forceExitTimer);\n process.exit(exitCode);\n }\n\n /** No `await` between read and assign — that gap is the re-entrancy window. */\n private runPhasesOnce(): Promise<number> {\n this.teardown ??= this.runPhases();\n return this.teardown;\n }\n\n /** Run the phases and report an exit code; no process-termination concerns. */\n private async runPhases(): Promise<number> {\n logger.info(\"Starting graceful shutdown...\");\n\n let exitCode = 0;\n\n try {\n const plugins = Array.from(this.context.getPlugins().values());\n\n // 1. stop the internal-telemetry reporter (no-op if never started).\n this.shutdownPhase = \"stopping internal telemetry reporter\";\n TelemetryReporter.getInstance()?.stop();\n\n // 2. abort active operations from plugins (in-flight executions, SSE\n // streams). Cancellation only — resource teardown (e.g. the\n // lakebase pools, the server's socket close) belongs in plugin\n // shutdown() hooks / lifecycle subscribers so other plugins can\n // still drain state through them.\n this.shutdownPhase = \"aborting active operations\";\n for (const plugin of plugins) {\n if (plugin.abortActiveOperations) {\n try {\n plugin.abortActiveOperations();\n } catch (err) {\n logger.error(\n \"Error aborting operations for plugin %s: %O\",\n plugin.name,\n err,\n );\n }\n }\n }\n\n // 3. run every plugin's shutdown() hook concurrently, each bounded\n // by a per-plugin timeout so one hung plugin cannot stall exit.\n this.shutdownPhase = \"plugin shutdown() hooks\";\n await Promise.all(\n plugins\n .filter((plugin) => typeof plugin.shutdown === \"function\")\n .map((plugin) => this.runPluginShutdown(plugin)),\n );\n\n // 4. notify lifecycle subscribers, bounded so a slow subscriber\n // cannot eat the remaining budget. The server plugin closes its\n // remaining sockets here, after other plugins have drained.\n this.shutdownPhase = \"shutdown lifecycle emit\";\n try {\n await this.raceWithTimeout(\n this.context.emitLifecycle(\"shutdown\"),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"shutdown lifecycle emit\",\n );\n } catch (err) {\n logger.error(\"Error emitting shutdown lifecycle event: %O\", err);\n }\n\n // 5. close the cache manager's storage (drains the persistent\n // Lakebase pool; no-op for in-memory storage) and flush telemetry.\n // Runs after the lifecycle emit so subscribers can still read the\n // cache. The two are independent (the flush never touches the\n // cache), so they run concurrently — each bounded so a stuck pool\n // drain or stalled OTLP export cannot eat the remaining budget.\n this.shutdownPhase = \"cache storage close + telemetry flush\";\n await Promise.all([this.closeCacheStorage(), this.flushTelemetry()]);\n\n logger.info(\"Graceful shutdown complete\");\n } catch (err) {\n // Exit 1 is reserved for an unexpected error thrown by the sequence\n // itself; every per-phase failure above is already caught and logged.\n logger.error(\"Error during graceful shutdown: %O\", err);\n exitCode = 1;\n }\n\n return exitCode;\n }\n\n /** Bounded and error-isolated. Reads the cache manager at phase-5 time. */\n private async closeCacheStorage(): Promise<void> {\n let cache: CacheManager | undefined;\n try {\n cache = CacheManager.getInstanceSync();\n } catch {\n // Never initialized — nothing to close.\n return;\n }\n try {\n await this.raceWithTimeout(\n cache.close(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"cache storage close\",\n );\n } catch (err) {\n logger.error(\"Error closing cache storage during shutdown: %O\", err);\n }\n }\n\n /** Bounded and error-isolated. Reads the telemetry manager at phase-5 time. */\n private async flushTelemetry(): Promise<void> {\n let telemetry: TelemetryManager | undefined;\n try {\n telemetry = TelemetryManager.getInstance();\n } catch {\n // Unavailable or mocked away — nothing to flush.\n return;\n }\n if (!telemetry) return;\n try {\n await this.raceWithTimeout(\n telemetry.shutdown(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"telemetry flush\",\n );\n } catch (err) {\n logger.error(\"Error flushing telemetry during shutdown: %O\", err);\n }\n }\n\n /**\n * Run a single plugin's `shutdown()` hook bounded by\n * {@link LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS}. Errors and timeouts\n * are logged but never thrown so one misbehaving plugin cannot block\n * the rest of the shutdown sequence.\n */\n private async runPluginShutdown(plugin: BasePlugin): Promise<void> {\n try {\n await this.raceWithTimeout(\n plugin.shutdown?.(),\n LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS,\n \"shutdown()\",\n );\n } catch (err) {\n logger.error(\"Error shutting down plugin %s: %O\", plugin.name, err);\n }\n }\n\n /**\n * Race `work` against a timeout. Rejects with a labeled error when the\n * timeout wins. A no-op rejection handler is attached to the work promise\n * before racing so a branch that rejects after the timeout already won\n * does not surface as an unhandledRejection.\n */\n private async raceWithTimeout<T>(\n work: Promise<T> | T,\n timeoutMs: number,\n label: string,\n ): Promise<T> {\n const promise = Promise.resolve(work);\n promise.catch(() => {});\n let timer: NodeJS.Timeout | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(\n () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n timer.unref();\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"judge.d.ts","names":[],"sources":["../../src/evals/judge.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"judge.d.ts","names":[],"sources":["../../src/evals/judge.ts"],"mappings":";;;;UA0BiB,WAAA;;EAEf,MAAA,EAAQ,YAAA;EAFO;EAIf,KAAA;;EAEA,KAAA;AAAA;;UAIe,UAAA;EACf,KAAA;EACA,SAAA;AAAA;AAFF;;;;;AAAA,iBAUsB,cAAA,CAAe,MAAA,EAAQ,WAAA,GAAc,OAAA;AAAA,iBAgB3C,iBAAA,CAAA"}
|
package/dist/evals/judge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//#region src/evals/judge.ts
|
|
2
2
|
let mod;
|
|
3
3
|
let enabled = false;
|
|
4
|
+
let notInstalled = false;
|
|
4
5
|
let configured = false;
|
|
5
6
|
let prevBaseUrl;
|
|
6
7
|
let prevApiKey;
|
|
@@ -19,8 +20,9 @@ async function configureJudge(config) {
|
|
|
19
20
|
configured = true;
|
|
20
21
|
mod.init({ defaultModel: config.model });
|
|
21
22
|
enabled = true;
|
|
22
|
-
} catch {
|
|
23
|
+
} catch (err) {
|
|
23
24
|
enabled = false;
|
|
25
|
+
notInstalled = isModuleNotFound(err);
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
function isJudgeConfigured() {
|
|
@@ -44,6 +46,10 @@ function restoreEnv(key, prev) {
|
|
|
44
46
|
if (prev === void 0) delete process.env[key];
|
|
45
47
|
else process.env[key] = prev;
|
|
46
48
|
}
|
|
49
|
+
/** True when a dynamic `import()` failed because the package isn't installed. */
|
|
50
|
+
function isModuleNotFound(err) {
|
|
51
|
+
return !!err && typeof err === "object" && "code" in err && (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND");
|
|
52
|
+
}
|
|
47
53
|
/** Normalize an autoevals `Score` into a `JudgeScore`. */
|
|
48
54
|
function toJudgeScore(s) {
|
|
49
55
|
const rationale = s.metadata?.rationale;
|
|
@@ -53,7 +59,7 @@ function toJudgeScore(s) {
|
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
function ensure() {
|
|
56
|
-
if (!enabled || !mod) throw new Error("LLM judge is not configured. Pass --judge-model and authenticate via --profile (or DATABRICKS_HOST/DATABRICKS_TOKEN) to use t.judge.*");
|
|
62
|
+
if (!enabled || !mod) throw new Error(notInstalled ? "LLM judge requires the optional `autoevals` package. Install it to use t.judge.*: npm i autoevals" : "LLM judge is not configured. Pass --judge-model and authenticate via --profile (or DATABRICKS_HOST/DATABRICKS_TOKEN) to use t.judge.*");
|
|
57
63
|
return mod;
|
|
58
64
|
}
|
|
59
65
|
/** Factuality of `output` vs an `expected` reference. */
|
package/dist/evals/judge.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"judge.js","names":[],"sources":["../../src/evals/judge.ts"],"sourcesContent":["import type { MlflowClient } from \"../connectors/mlflow\";\n\n/**\n * LLM-as-judge scoring via the `autoevals` library (the same scorers eve uses),\n * pointed at a Databricks serving endpoint. autoevals talks to an\n * OpenAI-compatible API; Databricks Model Serving exposes one at\n * `<host>/serving-endpoints`, so we set `OPENAI_BASE_URL`/`OPENAI_API_KEY` and\n * use the judge endpoint name as the model.\n *\n * There is no public REST to call Databricks' built-in judges directly (they're\n * Python/SDK-only and the rubric prompts live in the mlflow package), so we run\n * autoevals' equivalent scorers against a Databricks judge model.\n */\ntype AutoEvals = typeof import(\"autoevals\");\n\nlet mod: AutoEvals | undefined;\nlet enabled = false;\n// Whether configureJudge overwrote the OPENAI_* env vars and they still need\n// restoring, plus the values to restore them to.\nlet configured = false;\nlet prevBaseUrl: string | undefined;\nlet prevApiKey: string | undefined;\n\nexport interface JudgeConfig {\n /** Client for the workspace hosting the judge serving endpoint. */\n client: MlflowClient;\n /** Bearer token for the serving endpoint. */\n token: string;\n /** Serving endpoint name used as the judge model. */\n model: string;\n}\n\n/** A normalized judge result. `score` is 0..1. */\nexport interface JudgeScore {\n score: number;\n rationale?: string;\n}\n\n/**\n * Configure the judge once. Sets the OpenAI-compatible client env autoevals\n * reads and the default judge model. No-op-safe: on failure, judging stays\n * disabled and {@link isJudgeConfigured} returns false.\n */\nexport async function configureJudge(config: JudgeConfig): Promise<void> {\n try {\n mod = await import(\"autoevals\");\n prevBaseUrl = process.env.OPENAI_BASE_URL;\n prevApiKey = process.env.OPENAI_API_KEY;\n process.env.OPENAI_BASE_URL = config.client.servingEndpointsUrl();\n process.env.OPENAI_API_KEY = config.token;\n configured = true;\n mod.init({ defaultModel: config.model });\n enabled = true;\n } catch {\n enabled = false;\n }\n}\n\nexport function isJudgeConfigured(): boolean {\n return enabled;\n}\n\n/**\n * Restore the `OPENAI_*` env vars {@link configureJudge} set, so the judge\n * bearer doesn't linger in `process.env` (readable by any imported eval code)\n * after the run. Call once the run is done; safe when the judge was never\n * configured. Also disables judging so a late `t.judge.*` call fails cleanly\n * rather than hitting a torn-down client.\n */\nexport function teardownJudge(): void {\n if (!configured) return;\n restoreEnv(\"OPENAI_BASE_URL\", prevBaseUrl);\n restoreEnv(\"OPENAI_API_KEY\", prevApiKey);\n configured = false;\n enabled = false;\n}\n\nfunction restoreEnv(key: string, prev: string | undefined): void {\n if (prev === undefined) delete process.env[key];\n else process.env[key] = prev;\n}\n\n/** Normalize an autoevals `Score` into a `JudgeScore`. */\nexport function toJudgeScore(s: {\n score?: number | null;\n metadata?: Record<string, unknown>;\n}): JudgeScore {\n const rationale = s.metadata?.rationale;\n return {\n score: typeof s.score === \"number\" ? s.score : 0,\n rationale: typeof rationale === \"string\" ? rationale : undefined,\n };\n}\n\nfunction ensure(): AutoEvals {\n if (!enabled || !mod) {\n throw new Error(\n \"LLM judge is not configured. Pass --judge-model and authenticate via --profile (or DATABRICKS_HOST/DATABRICKS_TOKEN) to use t.judge.*\",\n );\n }\n return mod;\n}\n\n/** Factuality of `output` vs an `expected` reference. */\nexport async function judgeFactuality(args: {\n input: string;\n output: string;\n expected: string;\n}): Promise<JudgeScore> {\n return toJudgeScore(await ensure().Factuality(args));\n}\n\n/** Whether `output` answers the question in `input`, optionally constrained by `criteria`. */\nexport async function judgeClosedQA(args: {\n input: string;\n output: string;\n criteria: string;\n}): Promise<JudgeScore> {\n return toJudgeScore(await ensure().ClosedQA(args));\n}\n\n/**\n * A custom LLM judge defined by a prompt template + choice→score map — the\n * TypeScript analog of MLflow's custom `@scorer`.\n */\nexport async function judgeCustom(\n spec: {\n name: string;\n promptTemplate: string;\n choiceScores: Record<string, number>;\n },\n args: { input: string; output: string },\n): Promise<JudgeScore> {\n const scorer = ensure().LLMClassifierFromTemplate(spec);\n return toJudgeScore(await scorer(args));\n}\n"],"mappings":";AAeA,IAAI;AACJ,IAAI,UAAU;AAGd,IAAI,aAAa;AACjB,IAAI;AACJ,IAAI;;;;;;AAsBJ,eAAsB,eAAe,QAAoC;AACvE,KAAI;AACF,QAAM,MAAM,OAAO;AACnB,gBAAc,QAAQ,IAAI;AAC1B,eAAa,QAAQ,IAAI;AACzB,UAAQ,IAAI,kBAAkB,OAAO,OAAO,qBAAqB;AACjE,UAAQ,IAAI,iBAAiB,OAAO;AACpC,eAAa;AACb,MAAI,KAAK,EAAE,cAAc,OAAO,OAAO,CAAC;AACxC,YAAU;
|
|
1
|
+
{"version":3,"file":"judge.js","names":[],"sources":["../../src/evals/judge.ts"],"sourcesContent":["import type { MlflowClient } from \"../connectors/mlflow\";\n\n/**\n * LLM-as-judge scoring via the `autoevals` library (the same scorers eve uses),\n * pointed at a Databricks serving endpoint. autoevals talks to an\n * OpenAI-compatible API; Databricks Model Serving exposes one at\n * `<host>/serving-endpoints`, so we set `OPENAI_BASE_URL`/`OPENAI_API_KEY` and\n * use the judge endpoint name as the model.\n *\n * There is no public REST to call Databricks' built-in judges directly (they're\n * Python/SDK-only and the rubric prompts live in the mlflow package), so we run\n * autoevals' equivalent scorers against a Databricks judge model.\n */\ntype AutoEvals = typeof import(\"autoevals\");\n\nlet mod: AutoEvals | undefined;\nlet enabled = false;\n// Set when the dynamic import failed because `autoevals` (an optional peer) is\n// not installed, so `ensure()` can point at the fix instead of the auth path.\nlet notInstalled = false;\n// Whether configureJudge overwrote the OPENAI_* env vars and they still need\n// restoring, plus the values to restore them to.\nlet configured = false;\nlet prevBaseUrl: string | undefined;\nlet prevApiKey: string | undefined;\n\nexport interface JudgeConfig {\n /** Client for the workspace hosting the judge serving endpoint. */\n client: MlflowClient;\n /** Bearer token for the serving endpoint. */\n token: string;\n /** Serving endpoint name used as the judge model. */\n model: string;\n}\n\n/** A normalized judge result. `score` is 0..1. */\nexport interface JudgeScore {\n score: number;\n rationale?: string;\n}\n\n/**\n * Configure the judge once. Sets the OpenAI-compatible client env autoevals\n * reads and the default judge model. No-op-safe: on failure, judging stays\n * disabled and {@link isJudgeConfigured} returns false.\n */\nexport async function configureJudge(config: JudgeConfig): Promise<void> {\n try {\n mod = await import(\"autoevals\");\n prevBaseUrl = process.env.OPENAI_BASE_URL;\n prevApiKey = process.env.OPENAI_API_KEY;\n process.env.OPENAI_BASE_URL = config.client.servingEndpointsUrl();\n process.env.OPENAI_API_KEY = config.token;\n configured = true;\n mod.init({ defaultModel: config.model });\n enabled = true;\n } catch (err) {\n enabled = false;\n notInstalled = isModuleNotFound(err);\n }\n}\n\nexport function isJudgeConfigured(): boolean {\n return enabled;\n}\n\n/**\n * Restore the `OPENAI_*` env vars {@link configureJudge} set, so the judge\n * bearer doesn't linger in `process.env` (readable by any imported eval code)\n * after the run. Call once the run is done; safe when the judge was never\n * configured. Also disables judging so a late `t.judge.*` call fails cleanly\n * rather than hitting a torn-down client.\n */\nexport function teardownJudge(): void {\n if (!configured) return;\n restoreEnv(\"OPENAI_BASE_URL\", prevBaseUrl);\n restoreEnv(\"OPENAI_API_KEY\", prevApiKey);\n configured = false;\n enabled = false;\n}\n\nfunction restoreEnv(key: string, prev: string | undefined): void {\n if (prev === undefined) delete process.env[key];\n else process.env[key] = prev;\n}\n\n/** True when a dynamic `import()` failed because the package isn't installed. */\nfunction isModuleNotFound(err: unknown): boolean {\n return (\n !!err &&\n typeof err === \"object\" &&\n \"code\" in err &&\n (err.code === \"ERR_MODULE_NOT_FOUND\" || err.code === \"MODULE_NOT_FOUND\")\n );\n}\n\n/** Normalize an autoevals `Score` into a `JudgeScore`. */\nexport function toJudgeScore(s: {\n score?: number | null;\n metadata?: Record<string, unknown>;\n}): JudgeScore {\n const rationale = s.metadata?.rationale;\n return {\n score: typeof s.score === \"number\" ? s.score : 0,\n rationale: typeof rationale === \"string\" ? rationale : undefined,\n };\n}\n\nfunction ensure(): AutoEvals {\n if (!enabled || !mod) {\n throw new Error(\n notInstalled\n ? \"LLM judge requires the optional `autoevals` package. Install it to use t.judge.*: npm i autoevals\"\n : \"LLM judge is not configured. Pass --judge-model and authenticate via --profile (or DATABRICKS_HOST/DATABRICKS_TOKEN) to use t.judge.*\",\n );\n }\n return mod;\n}\n\n/** Factuality of `output` vs an `expected` reference. */\nexport async function judgeFactuality(args: {\n input: string;\n output: string;\n expected: string;\n}): Promise<JudgeScore> {\n return toJudgeScore(await ensure().Factuality(args));\n}\n\n/** Whether `output` answers the question in `input`, optionally constrained by `criteria`. */\nexport async function judgeClosedQA(args: {\n input: string;\n output: string;\n criteria: string;\n}): Promise<JudgeScore> {\n return toJudgeScore(await ensure().ClosedQA(args));\n}\n\n/**\n * A custom LLM judge defined by a prompt template + choice→score map — the\n * TypeScript analog of MLflow's custom `@scorer`.\n */\nexport async function judgeCustom(\n spec: {\n name: string;\n promptTemplate: string;\n choiceScores: Record<string, number>;\n },\n args: { input: string; output: string },\n): Promise<JudgeScore> {\n const scorer = ensure().LLMClassifierFromTemplate(spec);\n return toJudgeScore(await scorer(args));\n}\n"],"mappings":";AAeA,IAAI;AACJ,IAAI,UAAU;AAGd,IAAI,eAAe;AAGnB,IAAI,aAAa;AACjB,IAAI;AACJ,IAAI;;;;;;AAsBJ,eAAsB,eAAe,QAAoC;AACvE,KAAI;AACF,QAAM,MAAM,OAAO;AACnB,gBAAc,QAAQ,IAAI;AAC1B,eAAa,QAAQ,IAAI;AACzB,UAAQ,IAAI,kBAAkB,OAAO,OAAO,qBAAqB;AACjE,UAAQ,IAAI,iBAAiB,OAAO;AACpC,eAAa;AACb,MAAI,KAAK,EAAE,cAAc,OAAO,OAAO,CAAC;AACxC,YAAU;UACH,KAAK;AACZ,YAAU;AACV,iBAAe,iBAAiB,IAAI;;;AAIxC,SAAgB,oBAA6B;AAC3C,QAAO;;;;;;;;;AAUT,SAAgB,gBAAsB;AACpC,KAAI,CAAC,WAAY;AACjB,YAAW,mBAAmB,YAAY;AAC1C,YAAW,kBAAkB,WAAW;AACxC,cAAa;AACb,WAAU;;AAGZ,SAAS,WAAW,KAAa,MAAgC;AAC/D,KAAI,SAAS,OAAW,QAAO,QAAQ,IAAI;KACtC,SAAQ,IAAI,OAAO;;;AAI1B,SAAS,iBAAiB,KAAuB;AAC/C,QACE,CAAC,CAAC,OACF,OAAO,QAAQ,YACf,UAAU,QACT,IAAI,SAAS,0BAA0B,IAAI,SAAS;;;AAKzD,SAAgB,aAAa,GAGd;CACb,MAAM,YAAY,EAAE,UAAU;AAC9B,QAAO;EACL,OAAO,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;EAC/C,WAAW,OAAO,cAAc,WAAW,YAAY;EACxD;;AAGH,SAAS,SAAoB;AAC3B,KAAI,CAAC,WAAW,CAAC,IACf,OAAM,IAAI,MACR,eACI,sGACA,wIACL;AAEH,QAAO;;;AAIT,eAAsB,gBAAgB,MAId;AACtB,QAAO,aAAa,MAAM,QAAQ,CAAC,WAAW,KAAK,CAAC;;;AAItD,eAAsB,cAAc,MAIZ;AACtB,QAAO,aAAa,MAAM,QAAQ,CAAC,SAAS,KAAK,CAAC;;;;;;AAOpD,eAAsB,YACpB,MAKA,MACqB;AAErB,QAAO,aAAa,MADL,QAAQ,CAAC,0BAA0B,KAAK,CACtB,KAAK,CAAC"}
|
|
@@ -260,9 +260,14 @@ async function initAgentTracing() {
|
|
|
260
260
|
if (ucLocation) logger.info("MLflow agent tracing enabled (experiment %s, UC %s.%s.%s)", id, ucLocation.catalogName, ucLocation.schemaName, ucLocation.tablePrefix);
|
|
261
261
|
else logger.info("MLflow agent tracing enabled (experiment %s)", id);
|
|
262
262
|
} catch (err) {
|
|
263
|
-
logger.warn("MLflow agent tracing
|
|
263
|
+
if (isModuleNotFound(err)) logger.warn("MLflow agent tracing requires the optional `@mlflow/core` package. Install it to enable tracing: npm i @mlflow/core");
|
|
264
|
+
else logger.warn("MLflow agent tracing disabled: %O", err);
|
|
264
265
|
}
|
|
265
266
|
}
|
|
267
|
+
/** True when a dynamic `import()` failed because the package isn't installed. */
|
|
268
|
+
function isModuleNotFound(err) {
|
|
269
|
+
return !!err && typeof err === "object" && "code" in err && (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND");
|
|
270
|
+
}
|
|
266
271
|
const noopRecorder = { setOutputs() {} };
|
|
267
272
|
/**
|
|
268
273
|
* Seed the classic processor's global config once, AFTER
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mlflow.js","names":["#inner","#popTrace","#spanTypeKey","#agentSpanTypes","#maxTracked","#flushTimeoutMs","#ready","#forwarded","#agentTraceIds","#boundedFlush","trace"],"sources":["../../../src/plugins/agents/mlflow.ts"],"sourcesContent":["import type { UnityCatalogLocation } from \"@mlflow/core\";\nimport { SpanKind } from \"@opentelemetry/api\";\nimport type { SpanProcessor } from \"@opentelemetry/sdk-trace-base\";\n\nimport { createLogger } from \"../../logging/logger\";\nimport { TelemetryManager } from \"../../telemetry\";\n\nconst logger = createLogger(\"agents\");\n\ntype MlflowModule = typeof import(\"@mlflow/core\");\ntype MlflowClientInstance = InstanceType<MlflowModule[\"MlflowClient\"]>;\n\ninterface MlflowInitConfig {\n trackingUri: string;\n experimentId: string;\n host?: string;\n}\n\nlet mlflow: MlflowModule | undefined;\nlet enabled = false;\nlet initStarted = false;\nlet configured = false;\nlet initConfig: MlflowInitConfig | undefined;\n// The resolved UC trace location, or undefined for classic experiment storage.\nlet ucLocation: UnityCatalogLocation | undefined;\nlet gatedProcessor: GatedMlflowSpanProcessor | undefined;\n\n/**\n * Wraps mlflow's OTel `SpanProcessor` and scopes it to agent traces. Two jobs:\n *\n * 1. Stay inert until ready. The classic processor's `onStart` calls its own\n * `getConfig()`, which THROWS before `init()` runs — and that throw\n * propagates out of `tracer.startSpan()`, so it would break unrelated AppKit\n * spans (HTTP, analytics) created between `TelemetryManager.start()` and the\n * first agent turn. We contribute this to AppKit's single tracer provider\n * during `setup()`, but only start forwarding once `ready()` is called by\n * {@link ensureConfigured}. (The UC processor reads no global config and\n * can't throw here, but forwarding is gated uniformly either way.)\n *\n * 2. Let only agent turns become MLflow traces. mlflow roots a trace at EVERY\n * parentless span, and AppKit's single provider carries every HTTP/DB span —\n * so unscoped, every request would become an MLflow trace. mlflow stamps\n * `mlflow.spanType` on EVERY span it processes (defaulting to `UNKNOWN`), so\n * presence alone can't tell an agent turn from a plain request — we key on\n * the value (AGENT/TOOL) instead. At the root's `onEnd` we forward it (mlflow\n * exports the trace) only if some span in the trace carried an AGENT/TOOL\n * type; otherwise `popTrace` to discard the trace mlflow built in memory. The\n * real span type is set after `onStart` (the constructor stamps UNKNOWN\n * first), and children end before their root, so the flag is set by the time\n * the root decides.\n *\n * It also drops the exporters' own outbound spans at `onStart` (parentless\n * CLIENT — outgoing requests made outside any agent turn, e.g. mlflow/OTLP\n * shipping a trace). Forwarding those would loop: each upload is an HTTP call\n * that auto-instrumentation turns into a new span to trace and upload.\n *\n * ponytail: non-agent requests still build (then discard) an in-memory trace\n * tree — allocation-only, no network (export happens only when we forward the\n * root's `onEnd`). Fine at normal QPS; if a very high-QPS app makes the churn\n * matter, root MLflow at a detached agent span instead (costs the HTTP envelope\n * on the trace and splits the OTLP trace).\n */\nexport class GatedMlflowSpanProcessor implements SpanProcessor {\n #inner: SpanProcessor;\n #ready = false;\n // Spans we forwarded `onStart` for, so `onEnd` stays balanced — mlflow never\n // sees an end without a matching start.\n #forwarded = new WeakSet<object>();\n // OTel trace ids that contained at least one mlflow (AGENT/TOOL) span, so the\n // root's `onEnd` exports rather than discards. Cleared as each root ends.\n #agentTraceIds = new Set<string>();\n #popTrace: (otelTraceId: string) => void;\n #spanTypeKey: string;\n // The `mlflow.spanType` attribute values (JSON-stringified) that mark a trace\n // as an agent turn — AGENT/TOOL. Every other value (notably UNKNOWN, which\n // mlflow stamps on all non-agent spans) is treated as non-agent.\n #agentSpanTypes: ReadonlySet<string>;\n // Leak backstop for #agentTraceIds — far above real concurrency. See onEnd.\n #maxTracked: number;\n // Cap on how long forceFlush/shutdown wait for a stuck export.\n #flushTimeoutMs: number;\n\n constructor(\n inner: SpanProcessor,\n deps: {\n popTrace: (otelTraceId: string) => void;\n spanTypeKey: string;\n agentSpanTypes: ReadonlySet<string>;\n maxTracked?: number;\n flushTimeoutMs?: number;\n },\n ) {\n this.#inner = inner;\n this.#popTrace = deps.popTrace;\n this.#spanTypeKey = deps.spanTypeKey;\n this.#agentSpanTypes = deps.agentSpanTypes;\n this.#maxTracked = deps.maxTracked ?? 1024;\n this.#flushTimeoutMs = deps.flushTimeoutMs ?? 5000;\n }\n\n ready(): void {\n this.#ready = true;\n }\n\n onStart(\n span: Parameters<SpanProcessor[\"onStart\"]>[0],\n parentContext: Parameters<SpanProcessor[\"onStart\"]>[1],\n ): void {\n if (!this.#ready) return;\n // Drop the exporters' own outbound calls. A parentless (root) CLIENT span is\n // an outgoing request made outside any agent turn — e.g. mlflow or OTLP\n // shipping a trace. Forwarding those would loop: each upload is itself an\n // HTTP call that auto-instrumentation turns into a new span to trace and\n // upload.\n if (span.kind === SpanKind.CLIENT && !span.parentSpanContext?.spanId) {\n return;\n }\n this.#forwarded.add(span);\n this.#inner.onStart(span, parentContext);\n }\n\n onEnd(span: Parameters<SpanProcessor[\"onEnd\"]>[0]): void {\n if (!this.#forwarded.has(span)) return;\n const traceId = span.spanContext().traceId;\n // An AGENT/TOOL span ended in this trace — mark it for export. mlflow stamps\n // `mlflow.spanType` on every span (UNKNOWN by default), so match the value,\n // not mere presence; the real type is set after `onStart`, so `onEnd` is the\n // earliest we can read it.\n if (\n this.#agentSpanTypes.has(span.attributes[this.#spanTypeKey] as string) &&\n !this.#agentTraceIds.has(traceId)\n ) {\n // Normally an entry lives only until its root's onEnd deletes it. But a\n // root that ends BEFORE its agent child (streaming client-disconnect) or\n // never ends (crash) orphans the entry, and #agentTraceIds — unlike the\n // GC-safe #forwarded WeakSet — is keyed by string, so it can't self-clean.\n // FIFO-evict at the cap so an abandoned-trace pattern can't grow it\n // unboundedly over process uptime.\n // ponytail: evicting a still-live trace only mis-discards it, and that\n // needs >#maxTracked concurrent agent turns — far above real load.\n if (this.#agentTraceIds.size >= this.#maxTracked) {\n const oldest = this.#agentTraceIds.values().next().value;\n if (oldest !== undefined) this.#agentTraceIds.delete(oldest);\n }\n this.#agentTraceIds.add(traceId);\n }\n if (span.parentSpanContext?.spanId) {\n // Non-root: mlflow's own `onEnd` early-returns, but forward for balance.\n this.#inner.onEnd(span);\n return;\n }\n // Root span: export only agent traces; discard everything else so plain HTTP\n // requests never become MLflow traces. `delete` reports whether it was agent.\n if (this.#agentTraceIds.delete(traceId)) {\n this.#inner.onEnd(span);\n } else {\n this.#popTrace(traceId);\n }\n }\n\n forceFlush(): Promise<void> {\n return this.#boundedFlush(() => this.#inner.forceFlush());\n }\n\n shutdown(): Promise<void> {\n return this.#boundedFlush(() => this.#inner.shutdown());\n }\n\n // Bound the inner flush/shutdown wait: both exporters export fire-and-forget,\n // so a stuck export only wedges here (graceful shutdown), never a turn.\n // Resolves — not rejects — on timeout: the caller is tearing down.\n async #boundedFlush(op: () => Promise<void>): Promise<void> {\n let timer: NodeJS.Timeout | undefined;\n try {\n await Promise.race([\n op().catch((err) => {\n logger.warn(\"MLflow trace flush error: %O\", err);\n }),\n new Promise<void>((resolve) => {\n timer = setTimeout(() => {\n logger.warn(\n \"MLflow trace flush exceeded %dms; continuing (export may still be in flight)\",\n this.#flushTimeoutMs,\n );\n resolve();\n }, this.#flushTimeoutMs);\n timer.unref(); // don't keep the event loop alive on the timeout alone\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n}\n\n/**\n * Resolve the Unity Catalog trace location for the bound experiment, or\n * `undefined` for classic experiment-backed storage. Any failure falls back to\n * classic — a tracing misconfiguration must never break the agent.\n *\n * 1. Explicit env override — `MLFLOW_UC_CATALOG` + `MLFLOW_UC_SCHEMA` +\n * `MLFLOW_UC_TABLE_PREFIX`, all three required.\n * 2. Auto-detect from the linked Databricks experiment (numeric ids only, since\n * `GetExperiment` only accepts them): parse its `databricksTrace*` tags with\n * mlflow's own {@link ucLocationFromExperimentTags}, which also carries the\n * backend-populated spans/logs table names for custom-provisioned locations.\n * 3. Otherwise classic.\n *\n * `ucLocationFromExperimentTags` isn't on `@mlflow/core`'s public entrypoint, so\n * it's deep-imported like the exporter classes and covered by the tripwire test.\n */\nasync function resolveUcLocation(\n experimentId: string,\n client: MlflowClientInstance,\n): Promise<UnityCatalogLocation | undefined> {\n const catalogName = process.env.MLFLOW_UC_CATALOG?.trim();\n const schemaName = process.env.MLFLOW_UC_SCHEMA?.trim();\n const tablePrefix = process.env.MLFLOW_UC_TABLE_PREFIX?.trim();\n if (catalogName && schemaName && tablePrefix) {\n return { catalogName, schemaName, tablePrefix };\n }\n\n if (!/^\\d+$/.test(experimentId)) return undefined;\n\n try {\n const experiment = await client.getExperiment(experimentId);\n if (!experiment) return undefined;\n const { ucLocationFromExperimentTags } =\n await import(\"@mlflow/core/dist/core/destination\");\n return ucLocationFromExperimentTags(experiment.tags) ?? undefined;\n } catch (err) {\n logger.warn(\n \"MLflow UC trace-location auto-detect failed; using classic experiment storage: %O\",\n err,\n );\n return undefined;\n }\n}\n\n/**\n * Build mlflow's OTel `SpanProcessor` ourselves rather than letting `init()`\n * build and globally register its own tracer provider (its own `NodeSDK`). This\n * lets AppKit own the single global provider (OTLP + this processor), so agent\n * spans reach both MLflow and any OTLP endpoint without two SDKs racing for the\n * global slot.\n *\n * When a UC trace location is bound, builds the Unity Catalog processor +\n * exporter (V4 trace ids, spans uploaded to the experiment's UC table);\n * otherwise the classic experiment-backed processor. `createAuthProvider`,\n * `MlflowClient`, `InMemoryTraceManager` and `SpanAttributeKey` are all public\n * in `@mlflow/core`, so only the exporter/processor classes are deep-imported —\n * pinned to the exact version in package.json and guarded by a test that fails\n * loudly if a version bump renames them.\n *\n * Also resolves the hooks {@link GatedMlflowSpanProcessor} needs to scope\n * forwarding to agent traces: `popTrace` (to discard non-agent traces), the\n * `mlflow.spanType` attribute key, and the JSON-stringified AGENT/TOOL values\n * that mark a trace as an agent turn (mlflow stamps every span, defaulting to\n * UNKNOWN, so the gate must match the value, not presence).\n */\nasync function buildMlflowSpanProcessor(\n m: MlflowModule,\n client: MlflowClientInstance,\n ucLoc: UnityCatalogLocation | undefined,\n): Promise<{\n processor: SpanProcessor;\n popTrace: (otelTraceId: string) => void;\n spanTypeKey: string;\n agentSpanTypes: ReadonlySet<string>;\n}> {\n let processor: SpanProcessor;\n if (ucLoc) {\n const { DatabricksUCTableSpanExporter, DatabricksUCTableSpanProcessor } =\n await import(\"@mlflow/core/dist/exporters/uc_table\");\n processor = new DatabricksUCTableSpanProcessor(\n new DatabricksUCTableSpanExporter(client),\n ucLoc,\n );\n } else {\n const { MlflowSpanExporter, MlflowSpanProcessor } =\n await import(\"@mlflow/core/dist/exporters/mlflow\");\n processor = new MlflowSpanProcessor(new MlflowSpanExporter(client));\n }\n return {\n processor,\n popTrace: (otelTraceId) =>\n m.InMemoryTraceManager.getInstance().popTrace(otelTraceId),\n spanTypeKey: m.SpanAttributeKey.SPAN_TYPE,\n // mlflow JSON-stringifies attribute values, so the stored values are\n // `\"AGENT\"`/`\"TOOL\"` (quoted). Match that exact form.\n agentSpanTypes: new Set([\n JSON.stringify(m.SpanType.AGENT),\n JSON.stringify(m.SpanType.TOOL),\n ]),\n };\n}\n\n/** The bound MLflow experiment id, from the optional `experiment` resource. */\nfunction experimentId(): string | undefined {\n const id = process.env.MLFLOW_EXPERIMENT_ID?.trim();\n return id || undefined;\n}\n\n/**\n * Databricks host with a scheme. `@mlflow/core` uses `DATABRICKS_HOST`\n * verbatim to build request URLs and doesn't add `https://`, so a bare host\n * (`workspace.cloud.databricks.com`) makes `new URL()` throw. Pass an explicit\n * normalized host when the env var is set; when it isn't (profile-based auth),\n * return undefined and let the SDK read the host from `~/.databrickscfg`.\n */\nfunction normalizedDatabricksHost(): string | undefined {\n const raw = process.env.DATABRICKS_HOST?.trim();\n if (!raw) return undefined;\n return /^https?:\\/\\//i.test(raw) ? raw : `https://${raw}`;\n}\n\n/**\n * Initialize MLflow agent tracing once, when an experiment is bound — i.e. the\n * agents plugin's optional `experiment` resource is set (`MLFLOW_EXPERIMENT_ID`).\n * Called from the agents plugin's `setup()`, before `TelemetryManager.start()`.\n *\n * Rather than let `@mlflow/core`'s `init()` stand up and globally register its\n * own tracer provider (its `NodeSDK`, which would race AppKit's), we build the\n * span processor ourselves and contribute it to AppKit's single provider via\n * {@link TelemetryManager.registerSpanProcessor}. For the classic\n * experiment-backed processor, mlflow's global config is seeded by\n * {@link startAgentTracing} on the `\"setup:complete\"` lifecycle event (after\n * `start()`), with {@link ensureConfigured} as an idempotent lazy fallback; the\n * UC processor needs no seeded config, so that path never calls `init()`.\n *\n * The trace store is resolved here: a UC table prefix (env-configured or\n * auto-detected from the experiment's Databricks tags) vs. the classic\n * experiment. Auth is resolved by `@mlflow/core` from the app's own Databricks\n * credentials — `DATABRICKS_HOST`/`DATABRICKS_TOKEN` or a `~/.databrickscfg`\n * profile (`MLFLOW_TRACKING_URI=databricks://profile`) — so no tokens or OTLP\n * headers are wired by hand. A failure (missing creds, bad experiment) logs and\n * leaves tracing disabled rather than breaking the agent.\n *\n * Safe to call repeatedly; only the first call does work.\n */\nexport async function initAgentTracing(): Promise<void> {\n if (initStarted) return;\n initStarted = true;\n\n const id = experimentId();\n if (!id) return;\n\n try {\n mlflow = await import(\"@mlflow/core\");\n const host = normalizedDatabricksHost();\n initConfig = {\n trackingUri: process.env.MLFLOW_TRACKING_URI?.trim() || \"databricks\",\n experimentId: id,\n ...(host ? { host } : {}),\n };\n // One auth resolution + client, reused for UC auto-detect and the exporter.\n const authProvider = mlflow.createAuthProvider({\n trackingUri: initConfig.trackingUri,\n ...(host ? { host } : {}),\n });\n const client = new mlflow.MlflowClient({\n trackingUri: initConfig.trackingUri,\n authProvider,\n });\n ucLocation = await resolveUcLocation(id, client);\n const { processor, popTrace, spanTypeKey, agentSpanTypes } =\n await buildMlflowSpanProcessor(mlflow, client, ucLocation);\n gatedProcessor = new GatedMlflowSpanProcessor(processor, {\n popTrace,\n spanTypeKey,\n agentSpanTypes,\n });\n TelemetryManager.registerSpanProcessor(gatedProcessor);\n enabled = true;\n if (ucLocation) {\n logger.info(\n \"MLflow agent tracing enabled (experiment %s, UC %s.%s.%s)\",\n id,\n ucLocation.catalogName,\n ucLocation.schemaName,\n ucLocation.tablePrefix,\n );\n } else {\n logger.info(\"MLflow agent tracing enabled (experiment %s)\", id);\n }\n } catch (err) {\n logger.warn(\"MLflow agent tracing disabled: %O\", err);\n }\n}\n\n/**\n * Records a span's outputs. Callers get one from `traceAgent`/`traceTool`;\n * it's a no-op when tracing is disabled, so call sites never branch on it.\n */\nexport interface SpanRecorder {\n setOutputs(outputs: unknown): void;\n}\n\nconst noopRecorder: SpanRecorder = { setOutputs() {} };\n\n/**\n * Seed the classic processor's global config once, AFTER\n * `TelemetryManager.start()` has registered AppKit's provider — driven eagerly\n * by {@link startAgentTracing} on `\"setup:complete\"`, or lazily by {@link trace}\n * as a fallback — then enable forwarding on the gated processor. Returns whether\n * tracing is usable.\n *\n * The classic experiment-backed `MlflowSpanProcessor` reads\n * `getConfig().experimentId` in `onStart`, so it needs `init()` to seed mlflow's\n * global config. `init()` also stands up its own `NodeSDK` whose provider loses\n * the global slot to AppKit's already-registered one (non-fatal); we call it\n * only for that config side-effect. The UC processor carries its location and\n * reads no global config, so the UC path skips `init()` entirely — no second\n * `NodeSDK`, no competing global registration by `@mlflow/core`.\n */\nfunction ensureConfigured(): boolean {\n if (configured) return enabled;\n configured = true;\n // `gatedProcessor` guard is load-bearing: if buildMlflowSpanProcessor threw,\n // `mlflow` and `initConfig` are still set but there is no gate. Calling\n // `mlflow.init()` then would stand up mlflow's OWN ungated provider — and if\n // AppKit registered none (no OTLP, no processor) it wins the global slot,\n // routing every span into mlflow un-gated: the exact over-tracing + exporter\n // loop this file exists to prevent.\n if (!mlflow || !initConfig || !gatedProcessor) return false;\n try {\n if (!ucLocation) mlflow.init(initConfig);\n gatedProcessor.ready();\n return true;\n } catch (err) {\n enabled = false;\n logger.warn(\"MLflow agent tracing disabled (init failed): %O\", err);\n return false;\n }\n}\n\n/**\n * Seed mlflow's config eagerly, right after `TelemetryManager.start()` — the\n * agents plugin wires this to the `\"setup:complete\"` lifecycle event, before the\n * server serves any request. Doing it here means the request's own root span is\n * already forwarded when the first turn runs, so that turn assembles into a\n * trace instead of being dropped (mlflow roots a trace only at the top-level\n * span). Idempotent. `trace()` also seeds lazily, but that only fully rescues a\n * turn whose agent span is itself the trace root; an HTTP-wrapped first turn\n * seeded lazily loses its root span (already started — and dropped — before\n * `ready()`), so this eager path is the reliable one.\n */\nexport function startAgentTracing(): void {\n ensureConfigured();\n}\n\n/**\n * Run `fn` inside an MLflow span of `spanType` when tracing is enabled,\n * otherwise just run it (zero overhead). Spans auto-nest via the SDK's active\n * context, so a TOOL span opened inside an AGENT span's callback becomes its\n * child. The callback's resolved value is recorded as the span's outputs unless\n * it called `setOutputs` first; return `undefined` (or set outputs explicitly)\n * when the return value isn't the output you want traced.\n */\nasync function trace<T>(\n spanType: \"AGENT\" | \"TOOL\",\n name: string,\n inputs: unknown,\n fn: (span: SpanRecorder) => Promise<T>,\n): Promise<T> {\n if (!enabled || !mlflow) return fn(noopRecorder);\n const m = mlflow;\n if (!ensureConfigured()) return fn(noopRecorder);\n const type = spanType === \"AGENT\" ? m.SpanType.AGENT : m.SpanType.TOOL;\n return await m.withSpan<T>(\n async (span) => {\n if (inputs !== undefined) span.setInputs(inputs);\n let outputsSet = false;\n const result = await fn({\n setOutputs(outputs) {\n outputsSet = true;\n span.setOutputs(outputs);\n },\n });\n if (!outputsSet && result !== undefined) span.setOutputs(result);\n return result;\n },\n { name, spanType: type },\n );\n}\n\n/** Trace a turn's root AGENT span. See {@link trace}. */\nexport function traceAgent<T>(\n name: string,\n inputs: unknown,\n fn: (span: SpanRecorder) => Promise<T>,\n): Promise<T> {\n return trace(\"AGENT\", name, inputs, fn);\n}\n\n/** Trace a TOOL span, nested under the active AGENT span. See {@link trace}. */\nexport function traceTool<T>(\n name: string,\n inputs: unknown,\n fn: (span: SpanRecorder) => Promise<T>,\n): Promise<T> {\n return trace(\"TOOL\", name, inputs, fn);\n}\n\n/**\n * The MLflow trace id for the active turn, when tracing is enabled. Must be\n * read inside an agent span so eval runs can correlate the turn to its trace\n * and attach assessments. Returns undefined when tracing is off.\n *\n * Reads the context-active span rather than `getLastActiveTraceId()`: the\n * latter is only populated when a root span *ends* (on export), so mid-turn it\n * returns the previous turn's id — or, under concurrent turns, another turn's.\n */\nexport function currentTraceId(): string | undefined {\n if (!enabled || !mlflow) return undefined;\n try {\n return mlflow.getCurrentActiveSpan()?.traceId;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Link the active turn's trace to an MLflow run by id, via the `mlflow.sourceRun`\n * trace metadata. Used by eval runs so each case's trace shows under the run.\n * Must be called while a trace is active (inside an agent span). No-op when\n * tracing is disabled.\n */\nexport function linkTraceToRun(runId: string): void {\n if (!enabled || !mlflow) return;\n try {\n mlflow.updateCurrentTrace({ metadata: { \"mlflow.sourceRun\": runId } });\n } catch (err) {\n logger.warn(\"Failed to link trace to run %s: %O\", runId, err);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"mlflow.js","names":["#inner","#popTrace","#spanTypeKey","#agentSpanTypes","#maxTracked","#flushTimeoutMs","#ready","#forwarded","#agentTraceIds","#boundedFlush","trace"],"sources":["../../../src/plugins/agents/mlflow.ts"],"sourcesContent":["import type { UnityCatalogLocation } from \"@mlflow/core\";\nimport { SpanKind } from \"@opentelemetry/api\";\nimport type { SpanProcessor } from \"@opentelemetry/sdk-trace-base\";\n\nimport { createLogger } from \"../../logging/logger\";\nimport { TelemetryManager } from \"../../telemetry\";\n\nconst logger = createLogger(\"agents\");\n\ntype MlflowModule = typeof import(\"@mlflow/core\");\ntype MlflowClientInstance = InstanceType<MlflowModule[\"MlflowClient\"]>;\n\ninterface MlflowInitConfig {\n trackingUri: string;\n experimentId: string;\n host?: string;\n}\n\nlet mlflow: MlflowModule | undefined;\nlet enabled = false;\nlet initStarted = false;\nlet configured = false;\nlet initConfig: MlflowInitConfig | undefined;\n// The resolved UC trace location, or undefined for classic experiment storage.\nlet ucLocation: UnityCatalogLocation | undefined;\nlet gatedProcessor: GatedMlflowSpanProcessor | undefined;\n\n/**\n * Wraps mlflow's OTel `SpanProcessor` and scopes it to agent traces. Two jobs:\n *\n * 1. Stay inert until ready. The classic processor's `onStart` calls its own\n * `getConfig()`, which THROWS before `init()` runs — and that throw\n * propagates out of `tracer.startSpan()`, so it would break unrelated AppKit\n * spans (HTTP, analytics) created between `TelemetryManager.start()` and the\n * first agent turn. We contribute this to AppKit's single tracer provider\n * during `setup()`, but only start forwarding once `ready()` is called by\n * {@link ensureConfigured}. (The UC processor reads no global config and\n * can't throw here, but forwarding is gated uniformly either way.)\n *\n * 2. Let only agent turns become MLflow traces. mlflow roots a trace at EVERY\n * parentless span, and AppKit's single provider carries every HTTP/DB span —\n * so unscoped, every request would become an MLflow trace. mlflow stamps\n * `mlflow.spanType` on EVERY span it processes (defaulting to `UNKNOWN`), so\n * presence alone can't tell an agent turn from a plain request — we key on\n * the value (AGENT/TOOL) instead. At the root's `onEnd` we forward it (mlflow\n * exports the trace) only if some span in the trace carried an AGENT/TOOL\n * type; otherwise `popTrace` to discard the trace mlflow built in memory. The\n * real span type is set after `onStart` (the constructor stamps UNKNOWN\n * first), and children end before their root, so the flag is set by the time\n * the root decides.\n *\n * It also drops the exporters' own outbound spans at `onStart` (parentless\n * CLIENT — outgoing requests made outside any agent turn, e.g. mlflow/OTLP\n * shipping a trace). Forwarding those would loop: each upload is an HTTP call\n * that auto-instrumentation turns into a new span to trace and upload.\n *\n * ponytail: non-agent requests still build (then discard) an in-memory trace\n * tree — allocation-only, no network (export happens only when we forward the\n * root's `onEnd`). Fine at normal QPS; if a very high-QPS app makes the churn\n * matter, root MLflow at a detached agent span instead (costs the HTTP envelope\n * on the trace and splits the OTLP trace).\n */\nexport class GatedMlflowSpanProcessor implements SpanProcessor {\n #inner: SpanProcessor;\n #ready = false;\n // Spans we forwarded `onStart` for, so `onEnd` stays balanced — mlflow never\n // sees an end without a matching start.\n #forwarded = new WeakSet<object>();\n // OTel trace ids that contained at least one mlflow (AGENT/TOOL) span, so the\n // root's `onEnd` exports rather than discards. Cleared as each root ends.\n #agentTraceIds = new Set<string>();\n #popTrace: (otelTraceId: string) => void;\n #spanTypeKey: string;\n // The `mlflow.spanType` attribute values (JSON-stringified) that mark a trace\n // as an agent turn — AGENT/TOOL. Every other value (notably UNKNOWN, which\n // mlflow stamps on all non-agent spans) is treated as non-agent.\n #agentSpanTypes: ReadonlySet<string>;\n // Leak backstop for #agentTraceIds — far above real concurrency. See onEnd.\n #maxTracked: number;\n // Cap on how long forceFlush/shutdown wait for a stuck export.\n #flushTimeoutMs: number;\n\n constructor(\n inner: SpanProcessor,\n deps: {\n popTrace: (otelTraceId: string) => void;\n spanTypeKey: string;\n agentSpanTypes: ReadonlySet<string>;\n maxTracked?: number;\n flushTimeoutMs?: number;\n },\n ) {\n this.#inner = inner;\n this.#popTrace = deps.popTrace;\n this.#spanTypeKey = deps.spanTypeKey;\n this.#agentSpanTypes = deps.agentSpanTypes;\n this.#maxTracked = deps.maxTracked ?? 1024;\n this.#flushTimeoutMs = deps.flushTimeoutMs ?? 5000;\n }\n\n ready(): void {\n this.#ready = true;\n }\n\n onStart(\n span: Parameters<SpanProcessor[\"onStart\"]>[0],\n parentContext: Parameters<SpanProcessor[\"onStart\"]>[1],\n ): void {\n if (!this.#ready) return;\n // Drop the exporters' own outbound calls. A parentless (root) CLIENT span is\n // an outgoing request made outside any agent turn — e.g. mlflow or OTLP\n // shipping a trace. Forwarding those would loop: each upload is itself an\n // HTTP call that auto-instrumentation turns into a new span to trace and\n // upload.\n if (span.kind === SpanKind.CLIENT && !span.parentSpanContext?.spanId) {\n return;\n }\n this.#forwarded.add(span);\n this.#inner.onStart(span, parentContext);\n }\n\n onEnd(span: Parameters<SpanProcessor[\"onEnd\"]>[0]): void {\n if (!this.#forwarded.has(span)) return;\n const traceId = span.spanContext().traceId;\n // An AGENT/TOOL span ended in this trace — mark it for export. mlflow stamps\n // `mlflow.spanType` on every span (UNKNOWN by default), so match the value,\n // not mere presence; the real type is set after `onStart`, so `onEnd` is the\n // earliest we can read it.\n if (\n this.#agentSpanTypes.has(span.attributes[this.#spanTypeKey] as string) &&\n !this.#agentTraceIds.has(traceId)\n ) {\n // Normally an entry lives only until its root's onEnd deletes it. But a\n // root that ends BEFORE its agent child (streaming client-disconnect) or\n // never ends (crash) orphans the entry, and #agentTraceIds — unlike the\n // GC-safe #forwarded WeakSet — is keyed by string, so it can't self-clean.\n // FIFO-evict at the cap so an abandoned-trace pattern can't grow it\n // unboundedly over process uptime.\n // ponytail: evicting a still-live trace only mis-discards it, and that\n // needs >#maxTracked concurrent agent turns — far above real load.\n if (this.#agentTraceIds.size >= this.#maxTracked) {\n const oldest = this.#agentTraceIds.values().next().value;\n if (oldest !== undefined) this.#agentTraceIds.delete(oldest);\n }\n this.#agentTraceIds.add(traceId);\n }\n if (span.parentSpanContext?.spanId) {\n // Non-root: mlflow's own `onEnd` early-returns, but forward for balance.\n this.#inner.onEnd(span);\n return;\n }\n // Root span: export only agent traces; discard everything else so plain HTTP\n // requests never become MLflow traces. `delete` reports whether it was agent.\n if (this.#agentTraceIds.delete(traceId)) {\n this.#inner.onEnd(span);\n } else {\n this.#popTrace(traceId);\n }\n }\n\n forceFlush(): Promise<void> {\n return this.#boundedFlush(() => this.#inner.forceFlush());\n }\n\n shutdown(): Promise<void> {\n return this.#boundedFlush(() => this.#inner.shutdown());\n }\n\n // Bound the inner flush/shutdown wait: both exporters export fire-and-forget,\n // so a stuck export only wedges here (graceful shutdown), never a turn.\n // Resolves — not rejects — on timeout: the caller is tearing down.\n async #boundedFlush(op: () => Promise<void>): Promise<void> {\n let timer: NodeJS.Timeout | undefined;\n try {\n await Promise.race([\n op().catch((err) => {\n logger.warn(\"MLflow trace flush error: %O\", err);\n }),\n new Promise<void>((resolve) => {\n timer = setTimeout(() => {\n logger.warn(\n \"MLflow trace flush exceeded %dms; continuing (export may still be in flight)\",\n this.#flushTimeoutMs,\n );\n resolve();\n }, this.#flushTimeoutMs);\n timer.unref(); // don't keep the event loop alive on the timeout alone\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n}\n\n/**\n * Resolve the Unity Catalog trace location for the bound experiment, or\n * `undefined` for classic experiment-backed storage. Any failure falls back to\n * classic — a tracing misconfiguration must never break the agent.\n *\n * 1. Explicit env override — `MLFLOW_UC_CATALOG` + `MLFLOW_UC_SCHEMA` +\n * `MLFLOW_UC_TABLE_PREFIX`, all three required.\n * 2. Auto-detect from the linked Databricks experiment (numeric ids only, since\n * `GetExperiment` only accepts them): parse its `databricksTrace*` tags with\n * mlflow's own {@link ucLocationFromExperimentTags}, which also carries the\n * backend-populated spans/logs table names for custom-provisioned locations.\n * 3. Otherwise classic.\n *\n * `ucLocationFromExperimentTags` isn't on `@mlflow/core`'s public entrypoint, so\n * it's deep-imported like the exporter classes and covered by the tripwire test.\n */\nasync function resolveUcLocation(\n experimentId: string,\n client: MlflowClientInstance,\n): Promise<UnityCatalogLocation | undefined> {\n const catalogName = process.env.MLFLOW_UC_CATALOG?.trim();\n const schemaName = process.env.MLFLOW_UC_SCHEMA?.trim();\n const tablePrefix = process.env.MLFLOW_UC_TABLE_PREFIX?.trim();\n if (catalogName && schemaName && tablePrefix) {\n return { catalogName, schemaName, tablePrefix };\n }\n\n if (!/^\\d+$/.test(experimentId)) return undefined;\n\n try {\n const experiment = await client.getExperiment(experimentId);\n if (!experiment) return undefined;\n const { ucLocationFromExperimentTags } =\n await import(\"@mlflow/core/dist/core/destination\");\n return ucLocationFromExperimentTags(experiment.tags) ?? undefined;\n } catch (err) {\n logger.warn(\n \"MLflow UC trace-location auto-detect failed; using classic experiment storage: %O\",\n err,\n );\n return undefined;\n }\n}\n\n/**\n * Build mlflow's OTel `SpanProcessor` ourselves rather than letting `init()`\n * build and globally register its own tracer provider (its own `NodeSDK`). This\n * lets AppKit own the single global provider (OTLP + this processor), so agent\n * spans reach both MLflow and any OTLP endpoint without two SDKs racing for the\n * global slot.\n *\n * When a UC trace location is bound, builds the Unity Catalog processor +\n * exporter (V4 trace ids, spans uploaded to the experiment's UC table);\n * otherwise the classic experiment-backed processor. `createAuthProvider`,\n * `MlflowClient`, `InMemoryTraceManager` and `SpanAttributeKey` are all public\n * in `@mlflow/core`, so only the exporter/processor classes are deep-imported —\n * pinned to the exact version in package.json and guarded by a test that fails\n * loudly if a version bump renames them.\n *\n * Also resolves the hooks {@link GatedMlflowSpanProcessor} needs to scope\n * forwarding to agent traces: `popTrace` (to discard non-agent traces), the\n * `mlflow.spanType` attribute key, and the JSON-stringified AGENT/TOOL values\n * that mark a trace as an agent turn (mlflow stamps every span, defaulting to\n * UNKNOWN, so the gate must match the value, not presence).\n */\nasync function buildMlflowSpanProcessor(\n m: MlflowModule,\n client: MlflowClientInstance,\n ucLoc: UnityCatalogLocation | undefined,\n): Promise<{\n processor: SpanProcessor;\n popTrace: (otelTraceId: string) => void;\n spanTypeKey: string;\n agentSpanTypes: ReadonlySet<string>;\n}> {\n let processor: SpanProcessor;\n if (ucLoc) {\n const { DatabricksUCTableSpanExporter, DatabricksUCTableSpanProcessor } =\n await import(\"@mlflow/core/dist/exporters/uc_table\");\n processor = new DatabricksUCTableSpanProcessor(\n new DatabricksUCTableSpanExporter(client),\n ucLoc,\n );\n } else {\n const { MlflowSpanExporter, MlflowSpanProcessor } =\n await import(\"@mlflow/core/dist/exporters/mlflow\");\n processor = new MlflowSpanProcessor(new MlflowSpanExporter(client));\n }\n return {\n processor,\n popTrace: (otelTraceId) =>\n m.InMemoryTraceManager.getInstance().popTrace(otelTraceId),\n spanTypeKey: m.SpanAttributeKey.SPAN_TYPE,\n // mlflow JSON-stringifies attribute values, so the stored values are\n // `\"AGENT\"`/`\"TOOL\"` (quoted). Match that exact form.\n agentSpanTypes: new Set([\n JSON.stringify(m.SpanType.AGENT),\n JSON.stringify(m.SpanType.TOOL),\n ]),\n };\n}\n\n/** The bound MLflow experiment id, from the optional `experiment` resource. */\nfunction experimentId(): string | undefined {\n const id = process.env.MLFLOW_EXPERIMENT_ID?.trim();\n return id || undefined;\n}\n\n/**\n * Databricks host with a scheme. `@mlflow/core` uses `DATABRICKS_HOST`\n * verbatim to build request URLs and doesn't add `https://`, so a bare host\n * (`workspace.cloud.databricks.com`) makes `new URL()` throw. Pass an explicit\n * normalized host when the env var is set; when it isn't (profile-based auth),\n * return undefined and let the SDK read the host from `~/.databrickscfg`.\n */\nfunction normalizedDatabricksHost(): string | undefined {\n const raw = process.env.DATABRICKS_HOST?.trim();\n if (!raw) return undefined;\n return /^https?:\\/\\//i.test(raw) ? raw : `https://${raw}`;\n}\n\n/**\n * Initialize MLflow agent tracing once, when an experiment is bound — i.e. the\n * agents plugin's optional `experiment` resource is set (`MLFLOW_EXPERIMENT_ID`).\n * Called from the agents plugin's `setup()`, before `TelemetryManager.start()`.\n *\n * Rather than let `@mlflow/core`'s `init()` stand up and globally register its\n * own tracer provider (its `NodeSDK`, which would race AppKit's), we build the\n * span processor ourselves and contribute it to AppKit's single provider via\n * {@link TelemetryManager.registerSpanProcessor}. For the classic\n * experiment-backed processor, mlflow's global config is seeded by\n * {@link startAgentTracing} on the `\"setup:complete\"` lifecycle event (after\n * `start()`), with {@link ensureConfigured} as an idempotent lazy fallback; the\n * UC processor needs no seeded config, so that path never calls `init()`.\n *\n * The trace store is resolved here: a UC table prefix (env-configured or\n * auto-detected from the experiment's Databricks tags) vs. the classic\n * experiment. Auth is resolved by `@mlflow/core` from the app's own Databricks\n * credentials — `DATABRICKS_HOST`/`DATABRICKS_TOKEN` or a `~/.databrickscfg`\n * profile (`MLFLOW_TRACKING_URI=databricks://profile`) — so no tokens or OTLP\n * headers are wired by hand. A failure (missing creds, bad experiment) logs and\n * leaves tracing disabled rather than breaking the agent.\n *\n * Safe to call repeatedly; only the first call does work.\n */\nexport async function initAgentTracing(): Promise<void> {\n if (initStarted) return;\n initStarted = true;\n\n const id = experimentId();\n if (!id) return;\n\n try {\n mlflow = await import(\"@mlflow/core\");\n const host = normalizedDatabricksHost();\n initConfig = {\n trackingUri: process.env.MLFLOW_TRACKING_URI?.trim() || \"databricks\",\n experimentId: id,\n ...(host ? { host } : {}),\n };\n // One auth resolution + client, reused for UC auto-detect and the exporter.\n const authProvider = mlflow.createAuthProvider({\n trackingUri: initConfig.trackingUri,\n ...(host ? { host } : {}),\n });\n const client = new mlflow.MlflowClient({\n trackingUri: initConfig.trackingUri,\n authProvider,\n });\n ucLocation = await resolveUcLocation(id, client);\n const { processor, popTrace, spanTypeKey, agentSpanTypes } =\n await buildMlflowSpanProcessor(mlflow, client, ucLocation);\n gatedProcessor = new GatedMlflowSpanProcessor(processor, {\n popTrace,\n spanTypeKey,\n agentSpanTypes,\n });\n TelemetryManager.registerSpanProcessor(gatedProcessor);\n enabled = true;\n if (ucLocation) {\n logger.info(\n \"MLflow agent tracing enabled (experiment %s, UC %s.%s.%s)\",\n id,\n ucLocation.catalogName,\n ucLocation.schemaName,\n ucLocation.tablePrefix,\n );\n } else {\n logger.info(\"MLflow agent tracing enabled (experiment %s)\", id);\n }\n } catch (err) {\n if (isModuleNotFound(err)) {\n logger.warn(\n \"MLflow agent tracing requires the optional `@mlflow/core` package. \" +\n \"Install it to enable tracing: npm i @mlflow/core\",\n );\n } else {\n logger.warn(\"MLflow agent tracing disabled: %O\", err);\n }\n }\n}\n\n/** True when a dynamic `import()` failed because the package isn't installed. */\nfunction isModuleNotFound(err: unknown): boolean {\n return (\n !!err &&\n typeof err === \"object\" &&\n \"code\" in err &&\n (err.code === \"ERR_MODULE_NOT_FOUND\" || err.code === \"MODULE_NOT_FOUND\")\n );\n}\n\n/**\n * Records a span's outputs. Callers get one from `traceAgent`/`traceTool`;\n * it's a no-op when tracing is disabled, so call sites never branch on it.\n */\nexport interface SpanRecorder {\n setOutputs(outputs: unknown): void;\n}\n\nconst noopRecorder: SpanRecorder = { setOutputs() {} };\n\n/**\n * Seed the classic processor's global config once, AFTER\n * `TelemetryManager.start()` has registered AppKit's provider — driven eagerly\n * by {@link startAgentTracing} on `\"setup:complete\"`, or lazily by {@link trace}\n * as a fallback — then enable forwarding on the gated processor. Returns whether\n * tracing is usable.\n *\n * The classic experiment-backed `MlflowSpanProcessor` reads\n * `getConfig().experimentId` in `onStart`, so it needs `init()` to seed mlflow's\n * global config. `init()` also stands up its own `NodeSDK` whose provider loses\n * the global slot to AppKit's already-registered one (non-fatal); we call it\n * only for that config side-effect. The UC processor carries its location and\n * reads no global config, so the UC path skips `init()` entirely — no second\n * `NodeSDK`, no competing global registration by `@mlflow/core`.\n */\nfunction ensureConfigured(): boolean {\n if (configured) return enabled;\n configured = true;\n // `gatedProcessor` guard is load-bearing: if buildMlflowSpanProcessor threw,\n // `mlflow` and `initConfig` are still set but there is no gate. Calling\n // `mlflow.init()` then would stand up mlflow's OWN ungated provider — and if\n // AppKit registered none (no OTLP, no processor) it wins the global slot,\n // routing every span into mlflow un-gated: the exact over-tracing + exporter\n // loop this file exists to prevent.\n if (!mlflow || !initConfig || !gatedProcessor) return false;\n try {\n if (!ucLocation) mlflow.init(initConfig);\n gatedProcessor.ready();\n return true;\n } catch (err) {\n enabled = false;\n logger.warn(\"MLflow agent tracing disabled (init failed): %O\", err);\n return false;\n }\n}\n\n/**\n * Seed mlflow's config eagerly, right after `TelemetryManager.start()` — the\n * agents plugin wires this to the `\"setup:complete\"` lifecycle event, before the\n * server serves any request. Doing it here means the request's own root span is\n * already forwarded when the first turn runs, so that turn assembles into a\n * trace instead of being dropped (mlflow roots a trace only at the top-level\n * span). Idempotent. `trace()` also seeds lazily, but that only fully rescues a\n * turn whose agent span is itself the trace root; an HTTP-wrapped first turn\n * seeded lazily loses its root span (already started — and dropped — before\n * `ready()`), so this eager path is the reliable one.\n */\nexport function startAgentTracing(): void {\n ensureConfigured();\n}\n\n/**\n * Run `fn` inside an MLflow span of `spanType` when tracing is enabled,\n * otherwise just run it (zero overhead). Spans auto-nest via the SDK's active\n * context, so a TOOL span opened inside an AGENT span's callback becomes its\n * child. The callback's resolved value is recorded as the span's outputs unless\n * it called `setOutputs` first; return `undefined` (or set outputs explicitly)\n * when the return value isn't the output you want traced.\n */\nasync function trace<T>(\n spanType: \"AGENT\" | \"TOOL\",\n name: string,\n inputs: unknown,\n fn: (span: SpanRecorder) => Promise<T>,\n): Promise<T> {\n if (!enabled || !mlflow) return fn(noopRecorder);\n const m = mlflow;\n if (!ensureConfigured()) return fn(noopRecorder);\n const type = spanType === \"AGENT\" ? m.SpanType.AGENT : m.SpanType.TOOL;\n return await m.withSpan<T>(\n async (span) => {\n if (inputs !== undefined) span.setInputs(inputs);\n let outputsSet = false;\n const result = await fn({\n setOutputs(outputs) {\n outputsSet = true;\n span.setOutputs(outputs);\n },\n });\n if (!outputsSet && result !== undefined) span.setOutputs(result);\n return result;\n },\n { name, spanType: type },\n );\n}\n\n/** Trace a turn's root AGENT span. See {@link trace}. */\nexport function traceAgent<T>(\n name: string,\n inputs: unknown,\n fn: (span: SpanRecorder) => Promise<T>,\n): Promise<T> {\n return trace(\"AGENT\", name, inputs, fn);\n}\n\n/** Trace a TOOL span, nested under the active AGENT span. See {@link trace}. */\nexport function traceTool<T>(\n name: string,\n inputs: unknown,\n fn: (span: SpanRecorder) => Promise<T>,\n): Promise<T> {\n return trace(\"TOOL\", name, inputs, fn);\n}\n\n/**\n * The MLflow trace id for the active turn, when tracing is enabled. Must be\n * read inside an agent span so eval runs can correlate the turn to its trace\n * and attach assessments. Returns undefined when tracing is off.\n *\n * Reads the context-active span rather than `getLastActiveTraceId()`: the\n * latter is only populated when a root span *ends* (on export), so mid-turn it\n * returns the previous turn's id — or, under concurrent turns, another turn's.\n */\nexport function currentTraceId(): string | undefined {\n if (!enabled || !mlflow) return undefined;\n try {\n return mlflow.getCurrentActiveSpan()?.traceId;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Link the active turn's trace to an MLflow run by id, via the `mlflow.sourceRun`\n * trace metadata. Used by eval runs so each case's trace shows under the run.\n * Must be called while a trace is active (inside an agent span). No-op when\n * tracing is disabled.\n */\nexport function linkTraceToRun(runId: string): void {\n if (!enabled || !mlflow) return;\n try {\n mlflow.updateCurrentTrace({ metadata: { \"mlflow.sourceRun\": runId } });\n } catch (err) {\n logger.warn(\"Failed to link trace to run %s: %O\", runId, err);\n }\n}\n"],"mappings":"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"}
|
|
@@ -177,6 +177,12 @@ var TelemetryManager = class TelemetryManager {
|
|
|
177
177
|
* or repeated calls await the same in-flight flush. Awaited by the core
|
|
178
178
|
* lifecycle manager during graceful shutdown — that manager owns the
|
|
179
179
|
* process signal handlers, so telemetry no longer registers its own.
|
|
180
|
+
*
|
|
181
|
+
* Survives re-`initialize()`. `shutdownPromise` is deliberately *not* cleared
|
|
182
|
+
* when the flush settles, and that is safe: the memo is only ever reassigned
|
|
183
|
+
* for whatever providers are currently live, so a stale resolved promise can
|
|
184
|
+
* only be returned when there is nothing to flush. The covering test asserts
|
|
185
|
+
* every provider set across repeated initialize/shutdown cycles is flushed.
|
|
180
186
|
*/
|
|
181
187
|
async shutdown() {
|
|
182
188
|
const providers = [
|
|
@@ -200,6 +206,17 @@ var TelemetryManager = class TelemetryManager {
|
|
|
200
206
|
}
|
|
201
207
|
return this.shutdownPromise;
|
|
202
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Drop the singleton so the next {@link getInstance} builds a fresh manager.
|
|
211
|
+
*
|
|
212
|
+
* Does not flush: callers `shutdown()` first, then reset — the order
|
|
213
|
+
* `LifecycleManager.shutdown()` uses.
|
|
214
|
+
*
|
|
215
|
+
* @internal
|
|
216
|
+
*/
|
|
217
|
+
static reset() {
|
|
218
|
+
TelemetryManager.instance = void 0;
|
|
219
|
+
}
|
|
203
220
|
};
|
|
204
221
|
|
|
205
222
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { metrics } from \"@opentelemetry/api\";\nimport { logs } from \"@opentelemetry/api-logs\";\nimport { getNodeAutoInstrumentations } from \"@opentelemetry/auto-instrumentations-node\";\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-proto\";\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-proto\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-proto\";\nimport {\n type Instrumentation,\n registerInstrumentations as otelRegisterInstrumentations,\n} from \"@opentelemetry/instrumentation\";\nimport {\n detectResources,\n envDetector,\n hostDetector,\n processDetector,\n type Resource,\n resourceFromAttributes,\n} from \"@opentelemetry/resources\";\nimport {\n BatchLogRecordProcessor,\n LoggerProvider,\n} from \"@opentelemetry/sdk-logs\";\nimport {\n MeterProvider,\n PeriodicExportingMetricReader,\n} from \"@opentelemetry/sdk-metrics\";\nimport {\n BatchSpanProcessor,\n type SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\nimport { NodeTracerProvider } from \"@opentelemetry/sdk-trace-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\n\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryProvider } from \"./telemetry-provider\";\nimport { AppKitSampler } from \"./trace-sampler\";\nimport type { TelemetryConfig } from \"./types\";\n\nconst logger = createLogger(\"telemetry\");\n\n/**\n * Owns the app's OpenTelemetry providers, split into two phases so plugins can\n * contribute trace span processors before the tracer provider is built.\n *\n * - `initialize()` runs at app bootstrap, before plugin setup. It registers the\n * meter and logger providers eagerly, because OTel's metrics API has no lazy\n * proxy: a counter/histogram bound against the NoOp meter (as every connector\n * and the cache do in their constructors) stays NoOp for the process lifetime.\n * It does NOT register a tracer provider.\n * - `registerSpanProcessor()` is called by plugins during `setup()` to add a\n * span processor (e.g. an MLflow exporter) to the not-yet-built tracer.\n * - `start()` runs after all plugin `setup()` completes. It builds the single\n * global tracer provider with the OTLP processor (if configured) plus every\n * contributed processor. Deferring is safe for traces: OTel's ProxyTracer\n * rebinds tracers obtained before registration, and no span is emitted during\n * setup.\n */\nexport class TelemetryManager {\n private static readonly DEFAULT_EXPORT_INTERVAL_MS = 10000;\n private static readonly DEFAULT_FALLBACK_APP_NAME = \"databricks-app\";\n\n private static instance?: TelemetryManager;\n private resource?: Resource;\n private meterProvider?: MeterProvider;\n private loggerProvider?: LoggerProvider;\n private tracerProvider?: NodeTracerProvider;\n private readonly spanProcessors: SpanProcessor[] = [];\n private started = false;\n private shutdownPromise?: Promise<void>;\n\n /**\n * Create a scoped telemetry provider for a specific plugin.\n * The plugin's name will be used as the default tracer/meter name.\n * @param pluginName - The name of the plugin to create scoped telemetry for\n * @param telemetryConfig - The telemetry configuration for the plugin\n * @returns A scoped telemetry instance for the plugin\n */\n static getProvider(\n pluginName: string,\n telemetryConfig?: TelemetryOptions,\n ): TelemetryProvider {\n const globalManager = TelemetryManager.getInstance();\n return new TelemetryProvider(pluginName, globalManager, telemetryConfig);\n }\n\n private constructor() {}\n\n static getInstance(): TelemetryManager {\n if (!TelemetryManager.instance) {\n TelemetryManager.instance = new TelemetryManager();\n }\n return TelemetryManager.instance;\n }\n\n static initialize(config: Partial<TelemetryConfig> = {}): void {\n const instance = TelemetryManager.getInstance();\n instance._initialize(config);\n }\n\n /**\n * Contribute a span processor to the not-yet-built tracer provider. Called by\n * plugins during `setup()`. No-op with a warning once `start()` has run, since\n * a started provider's processors are immutable in OTel JS 2.x.\n */\n static registerSpanProcessor(processor: SpanProcessor): void {\n TelemetryManager.getInstance()._registerSpanProcessor(processor);\n }\n\n private _registerSpanProcessor(processor: SpanProcessor): void {\n if (this.started) {\n logger.warn(\n \"registerSpanProcessor called after start(); processor ignored. \" +\n \"Contribute span processors during plugin setup().\",\n );\n return;\n }\n this.spanProcessors.push(processor);\n }\n\n /**\n * Phase 1: register the meter and logger providers eagerly (before plugin\n * setup), so metric instruments bound in connector/cache constructors attach\n * to real meters. The tracer provider is deferred to `start()`.\n *\n * When no OTLP endpoint is configured, meter/logger registration is skipped;\n * a contributed span processor can still bring up tracing in `start()`.\n */\n private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.resource) return;\n this.resource = this.createResource(config);\n\n // OTLP exporters need an endpoint. Without one there is nothing to export\n // metrics/logs to, so skip those providers — but still capture the resource\n // and let `start()` bring up a tracer if a plugin contributed a processor.\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.meterProvider = new MeterProvider({\n resource: this.resource,\n readers: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n });\n metrics.setGlobalMeterProvider(this.meterProvider);\n\n this.loggerProvider = new LoggerProvider({\n resource: this.resource,\n processors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n });\n logs.setGlobalLoggerProvider(this.loggerProvider);\n\n // The OTLP trace exporter is the first span processor; contributed\n // processors join it in `start()`.\n this.spanProcessors.push(\n new BatchSpanProcessor(\n new OTLPTraceExporter({ headers: config.headers }),\n ),\n );\n\n this.registerInstrumentations(this.getDefaultInstrumentations());\n logger.debug(\"Meter/logger providers initialized\");\n } catch (error) {\n logger.error(\"Failed to initialize: %O\", error);\n }\n }\n\n /**\n * Phase 2: build and register the global tracer provider. Called by core\n * after every plugin's `setup()` completes, so all contributed span\n * processors are known. No-op when nothing needs tracing (no OTLP endpoint\n * and no contributed processor), preserving \"no telemetry unless configured\".\n *\n * `NodeTracerProvider.register()` installs the async-hooks context manager and\n * W3C propagators — the same wiring `NodeSDK.start()` did — so span nesting\n * across awaits is preserved.\n */\n static start(): void {\n TelemetryManager.getInstance()._start();\n }\n\n private _start(): void {\n if (this.started) return;\n this.started = true;\n\n if (this.spanProcessors.length === 0) {\n return;\n }\n\n try {\n this.tracerProvider = new NodeTracerProvider({\n resource: this.resource,\n sampler: new AppKitSampler(),\n spanProcessors: this.spanProcessors,\n });\n this.tracerProvider.register();\n logger.debug(\n \"Tracer provider started with %d span processor(s)\",\n this.spanProcessors.length,\n );\n } catch (error) {\n logger.error(\"Failed to start tracer provider: %O\", error);\n }\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n otelRegisterInstrumentations({\n // Instrumentations bind to the global providers registered by start()\n // (tracer) and _initialize() (meter/logger).\n instrumentations,\n });\n }\n\n private createResource(config: Partial<TelemetryConfig>): Resource {\n const serviceName =\n config.serviceName ||\n process.env.OTEL_SERVICE_NAME ||\n process.env.DATABRICKS_APP_NAME ||\n TelemetryManager.DEFAULT_FALLBACK_APP_NAME;\n const initialResource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_VERSION]: config.serviceVersion ?? undefined,\n });\n const detectedResource = detectResources({\n detectors: [envDetector, hostDetector, processDetector],\n });\n return initialResource.merge(detectedResource);\n }\n\n private getDefaultInstrumentations(): Instrumentation[] {\n return [\n ...getNodeAutoInstrumentations({\n //\n // enabled as a part of the server plugin\n //\n \"@opentelemetry/instrumentation-http\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-express\": {\n enabled: false,\n },\n //\n // reduce noise\n //\n \"@opentelemetry/instrumentation-fs\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-dns\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-net\": {\n enabled: false,\n },\n }),\n ];\n }\n\n /**\n * Flush and shut down the tracer, meter, and logger providers.\n *\n * Idempotent: the provider references are cleared synchronously and concurrent\n * or repeated calls await the same in-flight flush. Awaited by the core\n * lifecycle manager during graceful shutdown — that manager owns the\n * process signal handlers, so telemetry no longer registers its own.\n */\n async shutdown(): Promise<void> {\n const providers = [\n this.tracerProvider,\n this.meterProvider,\n this.loggerProvider,\n ].filter((p): p is NonNullable<typeof p> => p !== undefined);\n\n if (providers.length > 0) {\n this.tracerProvider = undefined;\n this.meterProvider = undefined;\n this.loggerProvider = undefined;\n this.shutdownPromise = (async () => {\n await Promise.all(\n providers.map(async (provider) => {\n try {\n await provider.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n }),\n );\n })();\n }\n\n return this.shutdownPromise;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { metrics } from \"@opentelemetry/api\";\nimport { logs } from \"@opentelemetry/api-logs\";\nimport { getNodeAutoInstrumentations } from \"@opentelemetry/auto-instrumentations-node\";\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-proto\";\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-proto\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-proto\";\nimport {\n type Instrumentation,\n registerInstrumentations as otelRegisterInstrumentations,\n} from \"@opentelemetry/instrumentation\";\nimport {\n detectResources,\n envDetector,\n hostDetector,\n processDetector,\n type Resource,\n resourceFromAttributes,\n} from \"@opentelemetry/resources\";\nimport {\n BatchLogRecordProcessor,\n LoggerProvider,\n} from \"@opentelemetry/sdk-logs\";\nimport {\n MeterProvider,\n PeriodicExportingMetricReader,\n} from \"@opentelemetry/sdk-metrics\";\nimport {\n BatchSpanProcessor,\n type SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\nimport { NodeTracerProvider } from \"@opentelemetry/sdk-trace-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\n\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryProvider } from \"./telemetry-provider\";\nimport { AppKitSampler } from \"./trace-sampler\";\nimport type { TelemetryConfig } from \"./types\";\n\nconst logger = createLogger(\"telemetry\");\n\n/**\n * Owns the app's OpenTelemetry providers, split into two phases so plugins can\n * contribute trace span processors before the tracer provider is built.\n *\n * - `initialize()` runs at app bootstrap, before plugin setup. It registers the\n * meter and logger providers eagerly, because OTel's metrics API has no lazy\n * proxy: a counter/histogram bound against the NoOp meter (as every connector\n * and the cache do in their constructors) stays NoOp for the process lifetime.\n * It does NOT register a tracer provider.\n * - `registerSpanProcessor()` is called by plugins during `setup()` to add a\n * span processor (e.g. an MLflow exporter) to the not-yet-built tracer.\n * - `start()` runs after all plugin `setup()` completes. It builds the single\n * global tracer provider with the OTLP processor (if configured) plus every\n * contributed processor. Deferring is safe for traces: OTel's ProxyTracer\n * rebinds tracers obtained before registration, and no span is emitted during\n * setup.\n */\nexport class TelemetryManager {\n private static readonly DEFAULT_EXPORT_INTERVAL_MS = 10000;\n private static readonly DEFAULT_FALLBACK_APP_NAME = \"databricks-app\";\n\n private static instance?: TelemetryManager;\n private resource?: Resource;\n private meterProvider?: MeterProvider;\n private loggerProvider?: LoggerProvider;\n private tracerProvider?: NodeTracerProvider;\n private readonly spanProcessors: SpanProcessor[] = [];\n private started = false;\n private shutdownPromise?: Promise<void>;\n\n /**\n * Create a scoped telemetry provider for a specific plugin.\n * The plugin's name will be used as the default tracer/meter name.\n * @param pluginName - The name of the plugin to create scoped telemetry for\n * @param telemetryConfig - The telemetry configuration for the plugin\n * @returns A scoped telemetry instance for the plugin\n */\n static getProvider(\n pluginName: string,\n telemetryConfig?: TelemetryOptions,\n ): TelemetryProvider {\n const globalManager = TelemetryManager.getInstance();\n return new TelemetryProvider(pluginName, globalManager, telemetryConfig);\n }\n\n private constructor() {}\n\n static getInstance(): TelemetryManager {\n if (!TelemetryManager.instance) {\n TelemetryManager.instance = new TelemetryManager();\n }\n return TelemetryManager.instance;\n }\n\n static initialize(config: Partial<TelemetryConfig> = {}): void {\n const instance = TelemetryManager.getInstance();\n instance._initialize(config);\n }\n\n /**\n * Contribute a span processor to the not-yet-built tracer provider. Called by\n * plugins during `setup()`. No-op with a warning once `start()` has run, since\n * a started provider's processors are immutable in OTel JS 2.x.\n */\n static registerSpanProcessor(processor: SpanProcessor): void {\n TelemetryManager.getInstance()._registerSpanProcessor(processor);\n }\n\n private _registerSpanProcessor(processor: SpanProcessor): void {\n if (this.started) {\n logger.warn(\n \"registerSpanProcessor called after start(); processor ignored. \" +\n \"Contribute span processors during plugin setup().\",\n );\n return;\n }\n this.spanProcessors.push(processor);\n }\n\n /**\n * Phase 1: register the meter and logger providers eagerly (before plugin\n * setup), so metric instruments bound in connector/cache constructors attach\n * to real meters. The tracer provider is deferred to `start()`.\n *\n * When no OTLP endpoint is configured, meter/logger registration is skipped;\n * a contributed span processor can still bring up tracing in `start()`.\n */\n private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.resource) return;\n this.resource = this.createResource(config);\n\n // OTLP exporters need an endpoint. Without one there is nothing to export\n // metrics/logs to, so skip those providers — but still capture the resource\n // and let `start()` bring up a tracer if a plugin contributed a processor.\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.meterProvider = new MeterProvider({\n resource: this.resource,\n readers: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n });\n metrics.setGlobalMeterProvider(this.meterProvider);\n\n this.loggerProvider = new LoggerProvider({\n resource: this.resource,\n processors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n });\n logs.setGlobalLoggerProvider(this.loggerProvider);\n\n // The OTLP trace exporter is the first span processor; contributed\n // processors join it in `start()`.\n this.spanProcessors.push(\n new BatchSpanProcessor(\n new OTLPTraceExporter({ headers: config.headers }),\n ),\n );\n\n this.registerInstrumentations(this.getDefaultInstrumentations());\n logger.debug(\"Meter/logger providers initialized\");\n } catch (error) {\n logger.error(\"Failed to initialize: %O\", error);\n }\n }\n\n /**\n * Phase 2: build and register the global tracer provider. Called by core\n * after every plugin's `setup()` completes, so all contributed span\n * processors are known. No-op when nothing needs tracing (no OTLP endpoint\n * and no contributed processor), preserving \"no telemetry unless configured\".\n *\n * `NodeTracerProvider.register()` installs the async-hooks context manager and\n * W3C propagators — the same wiring `NodeSDK.start()` did — so span nesting\n * across awaits is preserved.\n */\n static start(): void {\n TelemetryManager.getInstance()._start();\n }\n\n private _start(): void {\n if (this.started) return;\n this.started = true;\n\n if (this.spanProcessors.length === 0) {\n return;\n }\n\n try {\n this.tracerProvider = new NodeTracerProvider({\n resource: this.resource,\n sampler: new AppKitSampler(),\n spanProcessors: this.spanProcessors,\n });\n this.tracerProvider.register();\n logger.debug(\n \"Tracer provider started with %d span processor(s)\",\n this.spanProcessors.length,\n );\n } catch (error) {\n logger.error(\"Failed to start tracer provider: %O\", error);\n }\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n otelRegisterInstrumentations({\n // Instrumentations bind to the global providers registered by start()\n // (tracer) and _initialize() (meter/logger).\n instrumentations,\n });\n }\n\n private createResource(config: Partial<TelemetryConfig>): Resource {\n const serviceName =\n config.serviceName ||\n process.env.OTEL_SERVICE_NAME ||\n process.env.DATABRICKS_APP_NAME ||\n TelemetryManager.DEFAULT_FALLBACK_APP_NAME;\n const initialResource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_VERSION]: config.serviceVersion ?? undefined,\n });\n const detectedResource = detectResources({\n detectors: [envDetector, hostDetector, processDetector],\n });\n return initialResource.merge(detectedResource);\n }\n\n private getDefaultInstrumentations(): Instrumentation[] {\n return [\n ...getNodeAutoInstrumentations({\n //\n // enabled as a part of the server plugin\n //\n \"@opentelemetry/instrumentation-http\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-express\": {\n enabled: false,\n },\n //\n // reduce noise\n //\n \"@opentelemetry/instrumentation-fs\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-dns\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-net\": {\n enabled: false,\n },\n }),\n ];\n }\n\n /**\n * Flush and shut down the tracer, meter, and logger providers.\n *\n * Idempotent: the provider references are cleared synchronously and concurrent\n * or repeated calls await the same in-flight flush. Awaited by the core\n * lifecycle manager during graceful shutdown — that manager owns the\n * process signal handlers, so telemetry no longer registers its own.\n *\n * Survives re-`initialize()`. `shutdownPromise` is deliberately *not* cleared\n * when the flush settles, and that is safe: the memo is only ever reassigned\n * for whatever providers are currently live, so a stale resolved promise can\n * only be returned when there is nothing to flush. The covering test asserts\n * every provider set across repeated initialize/shutdown cycles is flushed.\n */\n async shutdown(): Promise<void> {\n const providers = [\n this.tracerProvider,\n this.meterProvider,\n this.loggerProvider,\n ].filter((p): p is NonNullable<typeof p> => p !== undefined);\n\n if (providers.length > 0) {\n this.tracerProvider = undefined;\n this.meterProvider = undefined;\n this.loggerProvider = undefined;\n this.shutdownPromise = (async () => {\n await Promise.all(\n providers.map(async (provider) => {\n try {\n await provider.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n }),\n );\n })();\n }\n\n return this.shutdownPromise;\n }\n\n /**\n * Drop the singleton so the next {@link getInstance} builds a fresh manager.\n *\n * Does not flush: callers `shutdown()` first, then reset — the order\n * `LifecycleManager.shutdown()` uses.\n *\n * @internal\n */\n static reset(): void {\n TelemetryManager.instance = undefined;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { PluginConstructor, PluginData, PluginMap } from "../shared/src/plugin.js";
|
|
2
|
+
import { CacheConfig } from "../shared/src/cache.js";
|
|
3
|
+
import "../shared/src/index.js";
|
|
4
|
+
import { WorkspaceClient } from "../workspace-client/index.js";
|
|
5
|
+
import { OboOption } from "./fixtures.js";
|
|
6
|
+
import { CreateMockWorkspaceClientOptions } from "./mock-workspace-client.js";
|
|
7
|
+
import { Server } from "node:http";
|
|
8
|
+
|
|
9
|
+
//#region src/testing/create-test-app.d.ts
|
|
10
|
+
/** Plugin descriptors, exactly as `createApp` takes them. */
|
|
11
|
+
type Plugins = PluginData<PluginConstructor, unknown, string>[];
|
|
12
|
+
/** Options for {@link createTestApp}. */
|
|
13
|
+
interface CreateTestAppOptions<T extends Plugins> {
|
|
14
|
+
/** The plugins under test, as `createApp` takes them. */
|
|
15
|
+
plugins?: T;
|
|
16
|
+
/** Dotted-path responses for the built-in mock. Refused when `client` is set. */
|
|
17
|
+
responses?: CreateMockWorkspaceClientOptions["responses"];
|
|
18
|
+
/**
|
|
19
|
+
* Make the built-in mock throw when a path with no declared response is
|
|
20
|
+
* called, rather than resolving `undefined`. Refused when `client` is set —
|
|
21
|
+
* configure it on your own client instead.
|
|
22
|
+
*/
|
|
23
|
+
strict?: CreateMockWorkspaceClientOptions["strict"];
|
|
24
|
+
/**
|
|
25
|
+
* Replaces the built-in mock. You then own `currentUser.me()` — boot reads
|
|
26
|
+
* `currentUser.id` and fails without it.
|
|
27
|
+
*/
|
|
28
|
+
client?: WorkspaceClient;
|
|
29
|
+
/** Extra env for the boot, restored on `close()`; satisfies declared resources. */
|
|
30
|
+
env?: Record<string, string>;
|
|
31
|
+
/** No socket; setup, validation, and teardown still run, request methods throw. */
|
|
32
|
+
server?: false;
|
|
33
|
+
/**
|
|
34
|
+
* Defaults to `"test"`. `"development"` is refused — it throws a `RangeError`
|
|
35
|
+
* in `get-port` on `port: 0`, boots Vite, and relaxes validation.
|
|
36
|
+
*
|
|
37
|
+
* Beyond refusing `development`, this decides error-response redaction:
|
|
38
|
+
* `errorHandlerMiddleware` returns the real message unless `NODE_ENV` is
|
|
39
|
+
* `production`, where a 5xx becomes `"Server error"`. Pass `"production"` to
|
|
40
|
+
* assert what a deployed app actually returns to a client.
|
|
41
|
+
*/
|
|
42
|
+
nodeEnv?: string;
|
|
43
|
+
/** Defaults to in-memory, which is what keeps boot offline. */
|
|
44
|
+
cache?: CacheConfig;
|
|
45
|
+
}
|
|
46
|
+
/** Per-request options for the {@link TestApp} HTTP methods. */
|
|
47
|
+
interface TestRequestOptions {
|
|
48
|
+
/** A non-string value is JSON-encoded with `content-type: application/json`. */
|
|
49
|
+
body?: unknown;
|
|
50
|
+
/** Merged last, so they win over anything the harness sets. */
|
|
51
|
+
headers?: Record<string, string>;
|
|
52
|
+
/** Same convention as `createMockRequest({ obo })`. */
|
|
53
|
+
obo?: OboOption;
|
|
54
|
+
/** Forwarded to `fetch`. */
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
/** A booted test app. */
|
|
58
|
+
interface TestApp<T extends Plugins> {
|
|
59
|
+
/**
|
|
60
|
+
* Plugin exports by manifest name. Nested rather than spread because `get` and
|
|
61
|
+
* `delete` are plausible plugin names and would collide with the request methods.
|
|
62
|
+
*/
|
|
63
|
+
plugins: PluginMap<T>;
|
|
64
|
+
/** The same object a handler resolves at runtime. */
|
|
65
|
+
client: WorkspaceClient;
|
|
66
|
+
/** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
/** The bound ephemeral port. Throws when `server: false`. */
|
|
69
|
+
port: number;
|
|
70
|
+
/** The underlying HTTP server, or `undefined` with `server: false`. */
|
|
71
|
+
server?: Server;
|
|
72
|
+
/** Release the app and restore env. Idempotent. */
|
|
73
|
+
close(): Promise<void>;
|
|
74
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
75
|
+
get(path: string, options?: TestRequestOptions): Promise<Response>;
|
|
76
|
+
post(path: string, options?: TestRequestOptions): Promise<Response>;
|
|
77
|
+
put(path: string, options?: TestRequestOptions): Promise<Response>;
|
|
78
|
+
patch(path: string, options?: TestRequestOptions): Promise<Response>;
|
|
79
|
+
delete(path: string, options?: TestRequestOptions): Promise<Response>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Wait for a server to finish binding and return the port it landed on.
|
|
83
|
+
*
|
|
84
|
+
* Needed with `port: 0`: `start()` returns once `listen()` is invoked, before
|
|
85
|
+
* the bind completes, so `address()` is null until the `listening` event fires.
|
|
86
|
+
* `createTestApp` does this for you — reach for it when hand-rolling a server.
|
|
87
|
+
*/
|
|
88
|
+
declare function getListeningPort(server: Server): Promise<number>;
|
|
89
|
+
/**
|
|
90
|
+
* Boot a real app — real Express wiring, routes, and resource validation — with
|
|
91
|
+
* no workspace, credentials, or network. `createTestPluginContext` is cheaper
|
|
92
|
+
* when you only need to unit-test wiring.
|
|
93
|
+
*
|
|
94
|
+
* Does **not** validate config values against `manifest.config.schema`; no
|
|
95
|
+
* runtime validator exists for that.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const app = await createTestApp({ plugins: [myPlugin()] });
|
|
100
|
+
* try {
|
|
101
|
+
* const res = await app.post("/api/my-plugin/thing", { body: { q: 1 }, obo: true });
|
|
102
|
+
* await expectStream(res).toEmit("status", "result");
|
|
103
|
+
* } finally {
|
|
104
|
+
* await app.close();
|
|
105
|
+
* }
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
declare function createTestApp<T extends Plugins>(options?: CreateTestAppOptions<T>): Promise<TestApp<T>>;
|
|
109
|
+
//#endregion
|
|
110
|
+
export { CreateTestAppOptions, TestApp, TestRequestOptions, createTestApp, getListeningPort };
|
|
111
|
+
//# sourceMappingURL=create-test-app.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-test-app.d.ts","names":[],"sources":["../../src/testing/create-test-app.ts"],"mappings":";;;;;;;;;AAqBgF;AAAA,KAoC3E,OAAA,GAAU,UAAA,CAAW,iBAAA;;UAGT,oBAAA,WAA+B,OAAA;EAHL;EAKzC,OAAA,GAAU,CAAA;EAFyB;EAKnC,SAAA,GAAY,gCAAA;EALkC;;;;;EAY9C,MAAA,GAAS,gCAAA;EA0BD;;;;EApBR,MAAA,GAAS,eAAA;EAhBT;EAmBA,GAAA,GAAM,MAAA;EAhBN;EAmBA,MAAA;EAZA;;;;;;;;;EAuBA,OAAA;EAGmB;EAAnB,KAAA,GAAQ,WAAA;AAAA;;UAIO,kBAAA;EAIL;EAFV,IAAA;EAMS;EAJT,OAAA,GAAU,MAAA;EAIU;EAFpB,GAAA,GAAM,SAAA;EAFN;EAIA,MAAA,GAAS,WAAA;AAAA;;UAIM,OAAA,WAAkB,OAAA;EAJxB;;;AAIX;EAKE,OAAA,EAAS,SAAA,CAAU,CAAA;EALG;EAOtB,MAAA,EAAQ,eAAA;EAFW;EAInB,OAAA;EAFQ;EAIR,IAAA;EAKS;EAHT,MAAA,GAAS,MAAA;EAMmB;EAH5B,KAAA,IAAS,OAAA;EAAA,CACR,MAAA,CAAO,YAAP,KAAwB,OAAA;EAEzB,GAAA,CAAI,IAAA,UAAc,OAAA,GAAU,kBAAA,GAAqB,OAAA,CAAQ,QAAA;EACzD,IAAA,CAAK,IAAA,UAAc,OAAA,GAAU,kBAAA,GAAqB,OAAA,CAAQ,QAAA;EAC1D,GAAA,CAAI,IAAA,UAAc,OAAA,GAAU,kBAAA,GAAqB,OAAA,CAAQ,QAAA;EACzD,KAAA,CAAM,IAAA,UAAc,OAAA,GAAU,kBAAA,GAAqB,OAAA,CAAQ,QAAA;EAC3D,MAAA,CAAO,IAAA,UAAc,OAAA,GAAU,kBAAA,GAAqB,OAAA,CAAQ,QAAA;AAAA;;;;;;;;iBA8BxC,gBAAA,CAAiB,MAAA,EAAQ,MAAA,GAAS,OAAA;;;;;;;;;;;;;;;;;;;;iBAmClC,aAAA,WAAwB,OAAA,CAAA,CAC5C,OAAA,GAAS,oBAAA,CAAqB,CAAA,IAC7B,OAAA,CAAQ,OAAA,CAAQ,CAAA"}
|