@databricks/appkit 0.45.0 → 0.47.0

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.
Files changed (113) hide show
  1. package/CLAUDE.md +1 -0
  2. package/dist/agents/databricks.d.ts +25 -1
  3. package/dist/agents/databricks.d.ts.map +1 -1
  4. package/dist/agents/databricks.js +20 -1
  5. package/dist/agents/databricks.js.map +1 -1
  6. package/dist/app/index.d.ts +49 -2
  7. package/dist/app/index.d.ts.map +1 -1
  8. package/dist/app/index.js +87 -10
  9. package/dist/app/index.js.map +1 -1
  10. package/dist/appkit/package.js +1 -1
  11. package/dist/beta.d.ts +2 -2
  12. package/dist/cli/commands/generate-types.js +9 -4
  13. package/dist/cli/commands/generate-types.js.map +1 -1
  14. package/dist/core/agent/load-agents.d.ts.map +1 -1
  15. package/dist/core/agent/load-agents.js +52 -0
  16. package/dist/core/agent/load-agents.js.map +1 -1
  17. package/dist/core/agent/types.d.ts +11 -0
  18. package/dist/core/agent/types.d.ts.map +1 -1
  19. package/dist/core/agent/types.js.map +1 -1
  20. package/dist/core/appkit.d.ts.map +1 -1
  21. package/dist/core/appkit.js +2 -0
  22. package/dist/core/appkit.js.map +1 -1
  23. package/dist/core/lifecycle-manager.js +183 -0
  24. package/dist/core/lifecycle-manager.js.map +1 -0
  25. package/dist/core/plugin-context.d.ts +19 -1
  26. package/dist/core/plugin-context.d.ts.map +1 -1
  27. package/dist/core/plugin-context.js +9 -3
  28. package/dist/core/plugin-context.js.map +1 -1
  29. package/dist/plugin/plugin.d.ts.map +1 -1
  30. package/dist/plugin/plugin.js +2 -1
  31. package/dist/plugin/plugin.js.map +1 -1
  32. package/dist/plugins/agents/agents.d.ts.map +1 -1
  33. package/dist/plugins/agents/agents.js +3 -3
  34. package/dist/plugins/agents/agents.js.map +1 -1
  35. package/dist/plugins/analytics/analytics.d.ts +16 -0
  36. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  37. package/dist/plugins/analytics/analytics.js +162 -1
  38. package/dist/plugins/analytics/analytics.js.map +1 -1
  39. package/dist/plugins/analytics/metric.js +7 -0
  40. package/dist/plugins/analytics/mv/cache.js +51 -0
  41. package/dist/plugins/analytics/mv/cache.js.map +1 -0
  42. package/dist/plugins/analytics/mv/constants.js +72 -0
  43. package/dist/plugins/analytics/mv/constants.js.map +1 -0
  44. package/dist/plugins/analytics/mv/formatters.js +151 -0
  45. package/dist/plugins/analytics/mv/formatters.js.map +1 -0
  46. package/dist/plugins/analytics/mv/index.js +6 -0
  47. package/dist/plugins/analytics/mv/registry.js +55 -0
  48. package/dist/plugins/analytics/mv/registry.js.map +1 -0
  49. package/dist/plugins/analytics/mv/schemas.js +178 -0
  50. package/dist/plugins/analytics/mv/schemas.js.map +1 -0
  51. package/dist/plugins/analytics/types.js.map +1 -1
  52. package/dist/plugins/lakebase/lakebase.d.ts +10 -2
  53. package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
  54. package/dist/plugins/lakebase/lakebase.js +18 -7
  55. package/dist/plugins/lakebase/lakebase.js.map +1 -1
  56. package/dist/plugins/server/index.d.ts +51 -1
  57. package/dist/plugins/server/index.d.ts.map +1 -1
  58. package/dist/plugins/server/index.js +110 -23
  59. package/dist/plugins/server/index.js.map +1 -1
  60. package/dist/registry/resource-registry.d.ts +9 -1
  61. package/dist/registry/resource-registry.d.ts.map +1 -1
  62. package/dist/registry/resource-registry.js +22 -5
  63. package/dist/registry/resource-registry.js.map +1 -1
  64. package/dist/schemas/metric-fqn.js +14 -0
  65. package/dist/schemas/metric-fqn.js.map +1 -0
  66. package/dist/shared/src/execute.d.ts +1 -3
  67. package/dist/shared/src/execute.d.ts.map +1 -1
  68. package/dist/shared/src/plugin.d.ts +16 -0
  69. package/dist/shared/src/plugin.d.ts.map +1 -1
  70. package/dist/shared/src/schemas/metric-fqn.js +78 -46
  71. package/dist/shared/src/schemas/metric-fqn.js.map +1 -1
  72. package/dist/shared/src/schemas/metric-source.js +90 -0
  73. package/dist/shared/src/schemas/metric-source.js.map +1 -0
  74. package/dist/stream/buffers.js +1 -1
  75. package/dist/stream/buffers.js.map +1 -1
  76. package/dist/stream/defaults.js +0 -2
  77. package/dist/stream/defaults.js.map +1 -1
  78. package/dist/stream/stream-manager.d.ts +2 -2
  79. package/dist/stream/stream-manager.d.ts.map +1 -1
  80. package/dist/stream/stream-manager.js +26 -24
  81. package/dist/stream/stream-manager.js.map +1 -1
  82. package/dist/stream/stream-registry.js +30 -23
  83. package/dist/stream/stream-registry.js.map +1 -1
  84. package/dist/stream/timers.js +17 -0
  85. package/dist/stream/timers.js.map +1 -0
  86. package/dist/stream/types.js.map +1 -1
  87. package/dist/telemetry/telemetry-manager.js +19 -13
  88. package/dist/telemetry/telemetry-manager.js.map +1 -1
  89. package/dist/type-generator/index.js +14 -7
  90. package/dist/type-generator/index.js.map +1 -1
  91. package/dist/type-generator/mv-registry/config.js +13 -31
  92. package/dist/type-generator/mv-registry/config.js.map +1 -1
  93. package/dist/type-generator/mv-registry/describe.js +1 -31
  94. package/dist/type-generator/mv-registry/describe.js.map +1 -1
  95. package/dist/type-generator/mv-registry/sync.js +1 -1
  96. package/dist/type-generator/mv-registry/sync.js.map +1 -1
  97. package/dist/type-generator/vite-plugin.d.ts +5 -1
  98. package/dist/type-generator/vite-plugin.d.ts.map +1 -1
  99. package/dist/type-generator/vite-plugin.js +21 -4
  100. package/dist/type-generator/vite-plugin.js.map +1 -1
  101. package/dist/utils/safe-handler.js +28 -0
  102. package/dist/utils/safe-handler.js.map +1 -0
  103. package/docs/api/appkit/Class.Plugin.md +2 -0
  104. package/docs/api/appkit/Class.ResourceRegistry.md +1 -1
  105. package/docs/api/appkit/Interface.AgentDefinition.md +11 -0
  106. package/docs/api/appkit/Interface.GenerationParams.md +58 -0
  107. package/docs/api/appkit/Interface.RegisteredAgent.md +11 -0
  108. package/docs/api/appkit.md +1 -0
  109. package/docs/development/type-generation.md +3 -3
  110. package/docs/plugins/analytics.md +172 -23
  111. package/llms.txt +1 -0
  112. package/package.json +1 -1
  113. package/sbom.cdx.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\nimport { AppKitError } from \"../errors/base\";\nimport { ExecutionError } from \"../errors/execution\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\nimport { SSEErrorCode, type StreamEntry, type StreamOperation } from \"./types\";\nimport { StreamValidator } from \"./validator\";\n\nconst logger = createLogger(\"stream\");\n\n// main entry point for Server-Sent events streaming\nexport class StreamManager {\n private activeOperations: Set<StreamOperation>;\n private streamRegistry: StreamRegistry;\n private sseWriter: SSEWriter;\n private maxEventSize: number;\n private bufferTTL: number;\n private disconnectGraceMs: number;\n\n constructor(options?: StreamConfig) {\n this.streamRegistry = new StreamRegistry(\n options?.maxActiveStreams ?? streamDefaults.maxActiveStreams,\n );\n this.sseWriter = new SSEWriter();\n this.maxEventSize = options?.maxEventSize ?? streamDefaults.maxEventSize;\n this.bufferTTL = options?.bufferTTL ?? streamDefaults.bufferTTL;\n this.disconnectGraceMs =\n options?.disconnectGraceMs ?? streamDefaults.disconnectGraceMs;\n this.activeOperations = new Set();\n }\n\n // main streaming method - handles new connection and reconnection\n async stream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const { streamId } = options || {};\n\n // check if response is already closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n // setup SSE headers\n this.sseWriter.setupHeaders(res);\n\n // handle reconnection\n if (streamId && StreamValidator.validateStreamId(streamId)) {\n const existingStream = this.streamRegistry.get(streamId);\n if (existingStream) {\n // Enforce per-user binding: the stream's owner key must match the\n // requesting caller's owner key. This prevents cross-user stream\n // takeover via guessed/leaked stream IDs (the SSE registry was\n // previously a global lookup with no authorization step).\n if (existingStream.ownerKey !== ownerKey) {\n this.sseWriter.writeError(\n res,\n randomUUID(),\n \"Stream not found or access denied\",\n SSEErrorCode.STREAM_FORBIDDEN,\n );\n res.end();\n return;\n }\n return this._attachToExistingStream(res, existingStream, options);\n }\n }\n\n // if stream does not exist, create a new one\n return this._createNewStream(res, handler, options, ownerKey);\n }\n\n // abort all active operations\n abortAll(): void {\n // pending disconnect-grace timers are cleared by streamRegistry.clear() below\n this.activeOperations.forEach((operation) => {\n if (operation.heartbeat) clearInterval(operation.heartbeat);\n operation.controller.abort(\n new DOMException(\"Server shutdown\", \"AbortError\"),\n );\n });\n this.activeOperations.clear();\n this.streamRegistry.clear();\n }\n\n // get the number of active operations\n getActiveCount(): number {\n return this.activeOperations.size;\n }\n\n // attach to existing stream\n private async _attachToExistingStream(\n res: IAppResponse,\n streamEntry: StreamEntry,\n options?: StreamConfig,\n ): Promise<void> {\n // handle reconnection - replay missed events\n const lastEventId = res.req?.headers[\"last-event-id\"];\n\n if (StreamValidator.validateEventId(lastEventId)) {\n // cast to string after validation\n const validEventId = lastEventId as string;\n if (streamEntry.eventBuffer.has(validEventId)) {\n const missedEvents =\n streamEntry.eventBuffer.getEventsSince(validEventId);\n // broadcast missed events to client\n for (const event of missedEvents) {\n if (options?.userSignal?.aborted) break;\n this.sseWriter.writeBufferedEvent(res, event);\n }\n } else {\n // buffer overflow - send warning\n this.sseWriter.writeBufferOverflowWarning(res, validEventId);\n }\n }\n\n // a reconnecting client cancels the pending disconnect-grace abort\n this._clearGraceTimer(streamEntry);\n\n // add client to stream entry\n streamEntry.clients.add(res);\n streamEntry.lastAccess = Date.now();\n\n // start heartbeat\n const combinedSignal = this._combineSignals(\n streamEntry.abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: streamEntry.abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n // handle client disconnect\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n streamEntry.clients.delete(res);\n this.activeOperations.delete(streamOperation);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // cleanup if stream is completed and no clients are connected\n if (streamEntry.isCompleted && streamEntry.clients.size === 0) {\n setTimeout(() => {\n if (streamEntry.clients.size === 0) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n }\n });\n\n // if stream is completed, close connection\n if (streamEntry.isCompleted) {\n res.end();\n // cleanup operation\n this.activeOperations.delete(streamOperation);\n clearInterval(heartbeat);\n }\n }\n private async _createNewStream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const streamId = options?.streamId ?? randomUUID();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n const abortController = new AbortController();\n\n // create event buffer\n const eventBuffer = new EventRingBuffer(\n options?.bufferSize ?? streamDefaults.bufferSize,\n );\n\n // setup signals and heartbeat\n const combinedSignal = this._combineSignals(\n abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // capture the current trace context at stream creation time\n const traceContext = context.active();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n clearInterval(heartbeat);\n return;\n }\n\n // create stream entry\n const streamEntry: StreamEntry = {\n streamId,\n ownerKey,\n generator: handler(combinedSignal),\n eventBuffer,\n clients: new Set([res]),\n isCompleted: false,\n lastAccess: Date.now(),\n abortController,\n traceContext,\n };\n this.streamRegistry.add(streamEntry);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n streamEntry.clients.delete(res);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n });\n\n await this._processGeneratorInBackground(streamEntry);\n\n // cleanup\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n }\n\n private async _processGeneratorInBackground(\n streamEntry: StreamEntry,\n ): Promise<void> {\n // run the entire generator processing within the captured trace context\n return context.with(streamEntry.traceContext, async () => {\n try {\n // retrieve all events from generator\n for await (const event of streamEntry.generator) {\n if (streamEntry.abortController.signal.aborted) break;\n const eventId = randomUUID();\n const eventData = JSON.stringify(event);\n\n // validate event size\n if (eventData.length > this.maxEventSize) {\n const errorMsg = `Event exceeds max size of ${this.maxEventSize} bytes`;\n const errorCode = SSEErrorCode.INVALID_REQUEST;\n // broadcast error to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n eventId,\n errorMsg,\n errorCode,\n );\n continue;\n }\n\n // buffer event for reconnection\n streamEntry.eventBuffer.add({\n id: eventId,\n type: event.type,\n data: eventData,\n timestamp: Date.now(),\n });\n\n // broadcast to all connected clients\n this._broadcastEventsToClients(streamEntry, eventId, event);\n streamEntry.lastAccess = Date.now();\n }\n\n streamEntry.isCompleted = true;\n\n // no late grace abort should fire on a completed stream\n this._clearGraceTimer(streamEntry);\n\n // close all clients\n this._closeAllClients(streamEntry);\n\n // cleanup if no clients are connected\n this._cleanupStream(streamEntry);\n } catch (error) {\n // Two distinct messages: a *raw* one for server-side logs (full\n // detail, statement fragments, correlation IDs) and a *client*\n // one for the SSE payload (sanitized, stable, safe to render in\n // a UI). Mixing them leaks upstream wording to anyone connected\n // to the stream — see CWE-209.\n const rawMsg =\n error instanceof Error ? error.message : \"Internal server error\";\n const clientMsg =\n error instanceof AppKitError\n ? error.clientMessage\n : \"Internal server error\";\n // Upstream structured code (e.g. RESULT_TOO_LARGE_FOR_JSON_FALLBACK,\n // NOT_IMPLEMENTED). UI should branch on this, not on `error`.\n const upstreamCode =\n error instanceof ExecutionError ? error.errorCode : undefined;\n const errorEventId = randomUUID();\n const errorCode = this._categorizeError(error);\n\n // client cancellation is a normal control-flow signal, not a failure\n if (errorCode === SSEErrorCode.STREAM_ABORTED) {\n logger.info(\"Stream aborted by client (code=%s)\", errorCode);\n streamEntry.isCompleted = true;\n this._clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._cleanupStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s upstreamCode=%s)\",\n rawMsg,\n errorCode,\n upstreamCode ?? \"n/a\",\n );\n\n const payload: Record<string, unknown> = {\n error: clientMsg,\n code: errorCode,\n };\n if (upstreamCode) payload.errorCode = upstreamCode;\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify(payload),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n clientMsg,\n errorCode,\n true,\n upstreamCode,\n );\n streamEntry.isCompleted = true;\n this._clearGraceTimer(streamEntry);\n }\n });\n }\n\n private _combineSignals(\n internalSignal?: AbortSignal,\n userSignal?: AbortSignal,\n ): AbortSignal {\n if (!userSignal) return internalSignal || new AbortController().signal;\n\n const signals = [internalSignal, userSignal].filter(\n Boolean,\n ) as AbortSignal[];\n const controller = new AbortController();\n\n signals.forEach((signal) => {\n if (signal?.aborted) {\n controller.abort(signal.reason);\n return;\n }\n\n signal?.addEventListener(\n \"abort\",\n () => {\n controller.abort(signal.reason);\n },\n { once: true },\n );\n });\n return controller.signal;\n }\n\n // broadcast events to all connected clients\n private _broadcastEventsToClients(\n streamEntry: StreamEntry,\n eventId: string,\n event: any,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeEvent(client, eventId, event);\n }\n }\n }\n\n // broadcast error to all connected clients\n private _broadcastErrorToClients(\n streamEntry: StreamEntry,\n eventId: string,\n errorMessage: string,\n errorCode: SSEErrorCode,\n closeClients: boolean = false,\n upstreamCode?: string,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(\n client,\n eventId,\n errorMessage,\n errorCode,\n upstreamCode,\n );\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n // close all connected clients\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\n }\n\n // abort the generator after the grace window unless a client reconnects first\n private _scheduleGraceAbort(streamEntry: StreamEntry): void {\n // clear any existing timer to avoid stacking\n this._clearGraceTimer(streamEntry);\n\n const timer = setTimeout(() => {\n streamEntry.disconnectGraceTimer = undefined;\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n streamEntry.abortController.abort(\n new DOMException(\"Client disconnected (grace expired)\", \"AbortError\"),\n );\n }\n }, this.disconnectGraceMs);\n\n // never keep the process alive solely for a grace timer\n timer.unref?.();\n streamEntry.disconnectGraceTimer = timer;\n }\n\n // clear a pending disconnect-grace timer, if any\n private _clearGraceTimer(streamEntry: StreamEntry): void {\n if (streamEntry.disconnectGraceTimer) {\n clearTimeout(streamEntry.disconnectGraceTimer);\n streamEntry.disconnectGraceTimer = undefined;\n }\n }\n\n // cleanup stream if no clients are connected\n private _cleanupStream(streamEntry: StreamEntry): void {\n if (streamEntry.clients.size === 0) {\n setTimeout(() => {\n if (streamEntry.clients.size === 0) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n }\n }\n\n private _categorizeError(error: unknown): SSEErrorCode {\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n if (message.includes(\"timeout\") || message.includes(\"timed out\")) {\n return SSEErrorCode.TIMEOUT;\n }\n\n if (message.includes(\"unavailable\") || message.includes(\"econnrefused\")) {\n return SSEErrorCode.TEMPORARY_UNAVAILABLE;\n }\n\n if (error.name === \"AbortError\") {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Defense-in-depth: upstream layers (SQL client, cache) may wrap an\n // AbortError into ExecutionError, losing `name` but keeping the message.\n if (\n message.includes(\"operation was aborted\") ||\n message.includes(\"the request was aborted\") ||\n message.includes(\"statement was canceled\")\n ) {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Detect upstream API errors (e.g., from Databricks SDK ApiError)\n if (\n \"statusCode\" in error &&\n typeof (error as any).statusCode === \"number\"\n ) {\n return SSEErrorCode.UPSTREAM_ERROR;\n }\n }\n\n return SSEErrorCode.INTERNAL_ERROR;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\nimport { AppKitError } from \"../errors/base\";\nimport { ExecutionError } from \"../errors/execution\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\nimport { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry, type StreamOperation } from \"./types\";\nimport { StreamValidator } from \"./validator\";\n\nconst logger = createLogger(\"stream\");\n\n// main entry point for Server-Sent events streaming\nexport class StreamManager {\n private activeOperations: Set<StreamOperation>;\n private streamRegistry: StreamRegistry;\n private sseWriter: SSEWriter;\n private maxEventSize: number;\n private bufferTTL: number;\n private disconnectGraceMs: number;\n\n constructor(options?: StreamConfig) {\n this.streamRegistry = new StreamRegistry(\n options?.maxActiveStreams ?? streamDefaults.maxActiveStreams,\n );\n this.sseWriter = new SSEWriter();\n this.maxEventSize = options?.maxEventSize ?? streamDefaults.maxEventSize;\n this.bufferTTL = options?.bufferTTL ?? streamDefaults.bufferTTL;\n this.disconnectGraceMs =\n options?.disconnectGraceMs ?? streamDefaults.disconnectGraceMs;\n this.activeOperations = new Set();\n }\n\n // main streaming method - handles new connection and reconnection\n async stream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const { streamId } = options || {};\n\n // check if response is already closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n // setup SSE headers\n this.sseWriter.setupHeaders(res);\n\n // handle reconnection\n if (streamId && StreamValidator.validateStreamId(streamId)) {\n const existingStream = this.streamRegistry.get(streamId);\n if (existingStream) {\n // Enforce per-user binding: the stream's owner key must match the\n // requesting caller's owner key. This prevents cross-user stream\n // takeover via guessed/leaked stream IDs (the SSE registry was\n // previously a global lookup with no authorization step).\n if (existingStream.ownerKey !== ownerKey) {\n this.sseWriter.writeError(\n res,\n randomUUID(),\n \"Stream not found or access denied\",\n SSEErrorCode.STREAM_FORBIDDEN,\n );\n res.end();\n return;\n }\n return this._attachToExistingStream(res, existingStream, options);\n }\n }\n\n // if stream does not exist, create a new one\n return this._createNewStream(res, handler, options, ownerKey);\n }\n\n // abort all active operations\n abortAll(): void {\n // pending disconnect-grace timers are cleared by streamRegistry.clear() below\n this.activeOperations.forEach((operation) => {\n if (operation.heartbeat) clearInterval(operation.heartbeat);\n operation.controller.abort(\n new DOMException(\"Server shutdown\", \"AbortError\"),\n );\n });\n this.activeOperations.clear();\n this.streamRegistry.clear();\n }\n\n // get the number of active operations\n getActiveCount(): number {\n return this.activeOperations.size;\n }\n\n // attach to existing stream\n private async _attachToExistingStream(\n res: IAppResponse,\n streamEntry: StreamEntry,\n options?: StreamConfig,\n ): Promise<void> {\n // handle reconnection - replay missed events\n const lastEventId = res.req?.headers[\"last-event-id\"];\n\n if (StreamValidator.validateEventId(lastEventId)) {\n // cast to string after validation\n const validEventId = lastEventId as string;\n if (streamEntry.eventBuffer.has(validEventId)) {\n const missedEvents =\n streamEntry.eventBuffer.getEventsSince(validEventId);\n // broadcast missed events to client\n for (const event of missedEvents) {\n if (options?.userSignal?.aborted) break;\n this.sseWriter.writeBufferedEvent(res, event);\n }\n } else {\n // buffer overflow - send warning\n this.sseWriter.writeBufferOverflowWarning(res, validEventId);\n }\n }\n\n // a reconnecting client cancels the pending disconnect-grace abort\n clearGraceTimer(streamEntry);\n\n // a reconnect cancels any pending registry removal so the entry isn't\n // pulled out from under the newly attached client\n clearRemovalTimer(streamEntry);\n\n // add client to stream entry\n streamEntry.clients.add(res);\n streamEntry.lastAccess = Date.now();\n\n // start heartbeat\n const combinedSignal = this._combineSignals(\n streamEntry.abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: streamEntry.abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n // handle client disconnect\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n streamEntry.clients.delete(res);\n this.activeOperations.delete(streamOperation);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // cleanup if stream is completed and no clients are connected\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n // if stream is completed, close connection\n if (streamEntry.isCompleted) {\n res.end();\n // cleanup operation\n this.activeOperations.delete(streamOperation);\n clearInterval(heartbeat);\n // we deliberately ended this client, so drop it from the entry and\n // schedule removal now instead of relying on the transport's `close`\n // event (the close handler's later delete is a safe no-op)\n streamEntry.clients.delete(res);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n }\n private async _createNewStream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const streamId = options?.streamId ?? randomUUID();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n const abortController = new AbortController();\n\n // create event buffer\n const eventBuffer = new EventRingBuffer(\n options?.bufferSize ?? streamDefaults.bufferSize,\n );\n\n // setup signals and heartbeat\n const combinedSignal = this._combineSignals(\n abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // capture the current trace context at stream creation time\n const traceContext = context.active();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n clearInterval(heartbeat);\n return;\n }\n\n // create stream entry\n const streamEntry: StreamEntry = {\n streamId,\n ownerKey,\n generator: handler(combinedSignal),\n eventBuffer,\n clients: new Set([res]),\n isCompleted: false,\n lastAccess: Date.now(),\n abortController,\n traceContext,\n };\n this.streamRegistry.add(streamEntry);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n streamEntry.clients.delete(res);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // if the stream already finished (completed or errored), schedule\n // registry removal once the buffer TTL elapses so completed streams\n // don't accumulate in the registry forever\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n await this._processGeneratorInBackground(streamEntry);\n\n // cleanup\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n }\n\n private async _processGeneratorInBackground(\n streamEntry: StreamEntry,\n ): Promise<void> {\n // run the entire generator processing within the captured trace context\n return context.with(streamEntry.traceContext, async () => {\n try {\n // retrieve all events from generator\n for await (const event of streamEntry.generator) {\n if (streamEntry.abortController.signal.aborted) break;\n const eventId = randomUUID();\n const eventData = JSON.stringify(event);\n\n // validate event size\n if (eventData.length > this.maxEventSize) {\n const errorMsg = `Event exceeds max size of ${this.maxEventSize} bytes`;\n const errorCode = SSEErrorCode.INVALID_REQUEST;\n // broadcast error to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n eventId,\n errorMsg,\n errorCode,\n );\n continue;\n }\n\n // buffer event for reconnection\n streamEntry.eventBuffer.add({\n id: eventId,\n type: event.type,\n data: eventData,\n timestamp: Date.now(),\n });\n\n // broadcast to all connected clients\n this._broadcastEventsToClients(streamEntry, eventId, event);\n streamEntry.lastAccess = Date.now();\n }\n\n this._finalizeStream(streamEntry);\n } catch (error) {\n // Two distinct messages: a *raw* one for server-side logs (full\n // detail, statement fragments, correlation IDs) and a *client*\n // one for the SSE payload (sanitized, stable, safe to render in\n // a UI). Mixing them leaks upstream wording to anyone connected\n // to the stream — see CWE-209.\n const rawMsg =\n error instanceof Error ? error.message : \"Internal server error\";\n const clientMsg =\n error instanceof AppKitError\n ? error.clientMessage\n : \"Internal server error\";\n // Upstream structured code (e.g. RESULT_TOO_LARGE_FOR_JSON_FALLBACK,\n // NOT_IMPLEMENTED). UI should branch on this, not on `error`.\n const upstreamCode =\n error instanceof ExecutionError ? error.errorCode : undefined;\n const errorEventId = randomUUID();\n const errorCode = this._categorizeError(error);\n\n // client cancellation is a normal control-flow signal, not a failure\n if (errorCode === SSEErrorCode.STREAM_ABORTED) {\n logger.info(\"Stream aborted by client (code=%s)\", errorCode);\n this._finalizeStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s upstreamCode=%s)\",\n rawMsg,\n errorCode,\n upstreamCode ?? \"n/a\",\n );\n\n const payload: Record<string, unknown> = {\n error: clientMsg,\n code: errorCode,\n };\n if (upstreamCode) payload.errorCode = upstreamCode;\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify(payload),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n clientMsg,\n errorCode,\n true,\n upstreamCode,\n );\n // the broadcast above already ended the connected clients; finalize\n // still detaches them so removal is scheduled without waiting on a\n // transport `close` event\n this._finalizeStream(streamEntry);\n }\n });\n }\n\n private _combineSignals(\n internalSignal?: AbortSignal,\n userSignal?: AbortSignal,\n ): AbortSignal {\n if (!userSignal) return internalSignal || new AbortController().signal;\n\n const signals = [internalSignal, userSignal].filter(\n Boolean,\n ) as AbortSignal[];\n const controller = new AbortController();\n\n signals.forEach((signal) => {\n if (signal?.aborted) {\n controller.abort(signal.reason);\n return;\n }\n\n signal?.addEventListener(\n \"abort\",\n () => {\n controller.abort(signal.reason);\n },\n { once: true },\n );\n });\n return controller.signal;\n }\n\n // broadcast events to all connected clients\n private _broadcastEventsToClients(\n streamEntry: StreamEntry,\n eventId: string,\n event: any,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeEvent(client, eventId, event);\n }\n }\n }\n\n // broadcast error to all connected clients\n private _broadcastErrorToClients(\n streamEntry: StreamEntry,\n eventId: string,\n errorMessage: string,\n errorCode: SSEErrorCode,\n closeClients: boolean = false,\n upstreamCode?: string,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(\n client,\n eventId,\n errorMessage,\n errorCode,\n upstreamCode,\n );\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n private _finalizeStream(streamEntry: StreamEntry): void {\n streamEntry.isCompleted = true;\n clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n\n // close all connected clients and remove them from the stream entry.\n // We are deliberately terminating these connections, so cleanup must not\n // depend on the transport emitting a `close` event for each client (it may\n // never fire). The close handlers' later `clients.delete(...)` calls remain\n // safe no-ops.\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\n streamEntry.clients.clear();\n }\n\n // abort the generator after the grace window unless a client reconnects first\n private _scheduleGraceAbort(streamEntry: StreamEntry): void {\n // clear any existing timer to avoid stacking\n clearGraceTimer(streamEntry);\n\n const timer = setTimeout(() => {\n streamEntry.disconnectGraceTimer = undefined;\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n streamEntry.abortController.abort(\n new DOMException(\"Client disconnected (grace expired)\", \"AbortError\"),\n );\n }\n }, this.disconnectGraceMs);\n\n // never keep the process alive solely for a grace timer\n timer.unref?.();\n streamEntry.disconnectGraceTimer = timer;\n }\n\n // schedule registry removal once a finished (completed or errored) stream\n // has no connected clients. The event buffer stays available for\n // reconnect replay for `bufferTTL` after the last client disconnects;\n // after that the stream entry is removed from the registry so it can be\n // garbage collected.\n private _scheduleRemovalAfterTTL(streamEntry: StreamEntry): void {\n if (!streamEntry.isCompleted || streamEntry.clients.size > 0) {\n return;\n }\n\n // at most one removal timer per stream: rescheduling replaces any\n // pending timer instead of stacking a new one (each pending timer pins\n // the entry's buffer/generator/trace context for the full TTL)\n clearRemovalTimer(streamEntry);\n\n // mark the moment the stream became idle so a reconnect during the TTL\n // window (which refreshes lastAccess) makes the pending timer a no-op\n streamEntry.lastAccess = Date.now();\n\n streamEntry.removalTimer = setTimeout(() => {\n streamEntry.removalTimer = undefined;\n // safety net: no-op if a client reconnected during the TTL window;\n // a fresh removal is scheduled when that client disconnects\n if (\n streamEntry.clients.size === 0 &&\n Date.now() - streamEntry.lastAccess >= this.bufferTTL\n ) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n\n // don't keep the process alive just to clean up finished streams\n streamEntry.removalTimer.unref?.();\n }\n\n private _categorizeError(error: unknown): SSEErrorCode {\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n if (message.includes(\"timeout\") || message.includes(\"timed out\")) {\n return SSEErrorCode.TIMEOUT;\n }\n\n if (message.includes(\"unavailable\") || message.includes(\"econnrefused\")) {\n return SSEErrorCode.TEMPORARY_UNAVAILABLE;\n }\n\n if (error.name === \"AbortError\") {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Defense-in-depth: upstream layers (SQL client, cache) may wrap an\n // AbortError into ExecutionError, losing `name` but keeping the message.\n if (\n message.includes(\"operation was aborted\") ||\n message.includes(\"the request was aborted\") ||\n message.includes(\"statement was canceled\")\n ) {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Detect upstream API errors (e.g., from Databricks SDK ApiError)\n if (\n \"statusCode\" in error &&\n typeof (error as any).statusCode === \"number\"\n ) {\n return SSEErrorCode.UPSTREAM_ERROR;\n }\n }\n\n return SSEErrorCode.INTERNAL_ERROR;\n }\n}\n"],"mappings":"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"}
@@ -1,50 +1,56 @@
1
- import { RingBuffer } from "./buffers.js";
2
1
  import { SSEErrorCode } from "./types.js";
2
+ import { clearGraceTimer, clearRemovalTimer } from "./timers.js";
3
3
 
4
4
  //#region src/stream/stream-registry.ts
5
5
  var StreamRegistry = class {
6
6
  streams;
7
+ maxActiveStreams;
7
8
  constructor(maxActiveStreams) {
8
- this.streams = new RingBuffer(maxActiveStreams, (entry) => entry.streamId);
9
+ this.streams = /* @__PURE__ */ new Map();
10
+ this.maxActiveStreams = maxActiveStreams;
9
11
  }
10
12
  add(entry) {
11
- if (this.streams.getSize() >= this.streams.capacity) this._evictOldestStream(entry.streamId);
12
- this.streams.add(entry);
13
+ if (!this.streams.has(entry.streamId) && this.streams.size >= this.maxActiveStreams) this._evictOldestStream(entry.streamId);
14
+ this.streams.set(entry.streamId, entry);
13
15
  }
14
16
  get(streamId) {
15
- return this.streams.get(streamId);
17
+ return this.streams.get(streamId) ?? null;
16
18
  }
17
19
  has(streamId) {
18
20
  return this.streams.has(streamId);
19
21
  }
20
22
  remove(streamId) {
21
- this.streams.remove(streamId);
23
+ this.streams.delete(streamId);
22
24
  }
23
25
  size() {
24
- return this.streams.getSize();
26
+ return this.streams.size;
25
27
  }
26
28
  clear() {
27
- const allStreams = this.streams.getAll();
28
- for (const stream of allStreams) {
29
- this._clearGraceTimer(stream);
29
+ for (const stream of this.streams.values()) {
30
30
  stream.abortController.abort("Server shutdown");
31
+ clearGraceTimer(stream);
32
+ clearRemovalTimer(stream);
31
33
  }
32
34
  this.streams.clear();
33
35
  }
34
- _clearGraceTimer(stream) {
35
- if (stream.disconnectGraceTimer) {
36
- clearTimeout(stream.disconnectGraceTimer);
37
- stream.disconnectGraceTimer = void 0;
38
- }
39
- }
40
36
  _evictOldestStream(excludeStreamId) {
41
- const allStreams = this.streams.getAll();
37
+ const allStreams = this.streams.values();
42
38
  let oldestStream = null;
43
39
  let oldestAccess = Infinity;
44
- for (const stream of allStreams) if (stream.streamId !== excludeStreamId && stream.lastAccess < oldestAccess) {
45
- oldestStream = stream;
46
- oldestAccess = stream.lastAccess;
40
+ let oldestCompletedStream = null;
41
+ let oldestCompletedAccess = Infinity;
42
+ for (const stream of allStreams) {
43
+ if (stream.streamId === excludeStreamId) continue;
44
+ if (stream.lastAccess < oldestAccess) {
45
+ oldestStream = stream;
46
+ oldestAccess = stream.lastAccess;
47
+ }
48
+ if (stream.isCompleted && stream.lastAccess < oldestCompletedAccess) {
49
+ oldestCompletedStream = stream;
50
+ oldestCompletedAccess = stream.lastAccess;
51
+ }
47
52
  }
53
+ oldestStream = oldestCompletedStream ?? oldestStream;
48
54
  if (oldestStream) {
49
55
  for (const client of oldestStream.clients) if (!client.writableEnded) try {
50
56
  client.write(`event: error\n`);
@@ -53,9 +59,10 @@ var StreamRegistry = class {
53
59
  code: SSEErrorCode.STREAM_EVICTED
54
60
  })}\n\n`);
55
61
  } catch (_error) {}
56
- this._clearGraceTimer(oldestStream);
57
- oldestStream.abortController.abort("Stream evicted");
58
- this.streams.remove(oldestStream.streamId);
62
+ clearGraceTimer(oldestStream);
63
+ oldestStream.abortController.abort(new DOMException("Stream evicted", "AbortError"));
64
+ clearRemovalTimer(oldestStream);
65
+ this.streams.delete(oldestStream.streamId);
59
66
  }
60
67
  }
61
68
  };
@@ -1 +1 @@
1
- {"version":3,"file":"stream-registry.js","names":[],"sources":["../../src/stream/stream-registry.ts"],"sourcesContent":["import { RingBuffer } from \"./buffers\";\nimport { SSEErrorCode, type StreamEntry } from \"./types\";\n\nexport class StreamRegistry {\n private streams: RingBuffer<StreamEntry>;\n\n constructor(maxActiveStreams: number) {\n this.streams = new RingBuffer<StreamEntry>(\n maxActiveStreams,\n (entry) => entry.streamId,\n );\n }\n\n // add a stream to the registry\n add(entry: StreamEntry): void {\n // enforce hard cap\n if (this.streams.getSize() >= this.streams.capacity) {\n this._evictOldestStream(entry.streamId);\n }\n\n this.streams.add(entry);\n }\n\n // get a stream from the registry\n get(streamId: string): StreamEntry | null {\n return this.streams.get(streamId);\n }\n\n // check if a stream exists in the registry\n has(streamId: string): boolean {\n return this.streams.has(streamId);\n }\n\n // remove a stream from the registry\n remove(streamId: string): void {\n this.streams.remove(streamId);\n }\n\n // get the number of streams in the registry\n size(): number {\n return this.streams.getSize();\n }\n\n clear(): void {\n const allStreams = this.streams.getAll();\n\n for (const stream of allStreams) {\n this._clearGraceTimer(stream);\n stream.abortController.abort(\"Server shutdown\");\n }\n\n this.streams.clear();\n }\n\n // clear a pending grace timer so a removed stream isn't pinned until it fires\n private _clearGraceTimer(stream: StreamEntry): void {\n if (stream.disconnectGraceTimer) {\n clearTimeout(stream.disconnectGraceTimer);\n stream.disconnectGraceTimer = undefined;\n }\n }\n\n // evict the oldest stream from the registry\n private _evictOldestStream(excludeStreamId: string): void {\n const allStreams = this.streams.getAll();\n let oldestStream: StreamEntry | null = null;\n let oldestAccess = Infinity;\n\n // find the least recently accessed stream\n for (const stream of allStreams) {\n if (\n stream.streamId !== excludeStreamId &&\n stream.lastAccess < oldestAccess\n ) {\n oldestStream = stream;\n oldestAccess = stream.lastAccess;\n }\n }\n\n // abort the oldest stream\n if (oldestStream) {\n // broadcast stream eviction error to all clients\n for (const client of oldestStream.clients) {\n if (!client.writableEnded) {\n try {\n client.write(`event: error\\n`);\n client.write(\n `data: ${JSON.stringify({ error: \"Stream evicted\", code: SSEErrorCode.STREAM_EVICTED })}\\n\\n`,\n );\n } catch (_error) {\n // ignore\n }\n }\n }\n this._clearGraceTimer(oldestStream);\n oldestStream.abortController.abort(\"Stream evicted\");\n this.streams.remove(oldestStream.streamId);\n }\n }\n}\n"],"mappings":";;;;AAGA,IAAa,iBAAb,MAA4B;CAC1B,AAAQ;CAER,YAAY,kBAA0B;AACpC,OAAK,UAAU,IAAI,WACjB,mBACC,UAAU,MAAM,SAClB;;CAIH,IAAI,OAA0B;AAE5B,MAAI,KAAK,QAAQ,SAAS,IAAI,KAAK,QAAQ,SACzC,MAAK,mBAAmB,MAAM,SAAS;AAGzC,OAAK,QAAQ,IAAI,MAAM;;CAIzB,IAAI,UAAsC;AACxC,SAAO,KAAK,QAAQ,IAAI,SAAS;;CAInC,IAAI,UAA2B;AAC7B,SAAO,KAAK,QAAQ,IAAI,SAAS;;CAInC,OAAO,UAAwB;AAC7B,OAAK,QAAQ,OAAO,SAAS;;CAI/B,OAAe;AACb,SAAO,KAAK,QAAQ,SAAS;;CAG/B,QAAc;EACZ,MAAM,aAAa,KAAK,QAAQ,QAAQ;AAExC,OAAK,MAAM,UAAU,YAAY;AAC/B,QAAK,iBAAiB,OAAO;AAC7B,UAAO,gBAAgB,MAAM,kBAAkB;;AAGjD,OAAK,QAAQ,OAAO;;CAItB,AAAQ,iBAAiB,QAA2B;AAClD,MAAI,OAAO,sBAAsB;AAC/B,gBAAa,OAAO,qBAAqB;AACzC,UAAO,uBAAuB;;;CAKlC,AAAQ,mBAAmB,iBAA+B;EACxD,MAAM,aAAa,KAAK,QAAQ,QAAQ;EACxC,IAAI,eAAmC;EACvC,IAAI,eAAe;AAGnB,OAAK,MAAM,UAAU,WACnB,KACE,OAAO,aAAa,mBACpB,OAAO,aAAa,cACpB;AACA,kBAAe;AACf,kBAAe,OAAO;;AAK1B,MAAI,cAAc;AAEhB,QAAK,MAAM,UAAU,aAAa,QAChC,KAAI,CAAC,OAAO,cACV,KAAI;AACF,WAAO,MAAM,iBAAiB;AAC9B,WAAO,MACL,SAAS,KAAK,UAAU;KAAE,OAAO;KAAkB,MAAM,aAAa;KAAgB,CAAC,CAAC,MACzF;YACM,QAAQ;AAKrB,QAAK,iBAAiB,aAAa;AACnC,gBAAa,gBAAgB,MAAM,iBAAiB;AACpD,QAAK,QAAQ,OAAO,aAAa,SAAS"}
1
+ {"version":3,"file":"stream-registry.js","names":[],"sources":["../../src/stream/stream-registry.ts"],"sourcesContent":["import { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry } from \"./types\";\n\nexport class StreamRegistry {\n // keyed storage with explicit, policy-driven eviction. A ring buffer is\n // unsuitable here: it overwrites by insertion slot, so evicting an entry\n // chosen by policy (e.g. a completed stream) and then adding would\n // silently clobber an unrelated live stream sitting in the oldest slot.\n private streams: Map<string, StreamEntry>;\n private maxActiveStreams: number;\n\n constructor(maxActiveStreams: number) {\n this.streams = new Map();\n this.maxActiveStreams = maxActiveStreams;\n }\n\n // add a stream to the registry\n add(entry: StreamEntry): void {\n // enforce hard cap, but only when inserting a genuinely new key:\n // re-adding an existing streamId updates in place and doesn't grow the\n // map, so evicting another stream for it would destroy an innocent one\n if (\n !this.streams.has(entry.streamId) &&\n this.streams.size >= this.maxActiveStreams\n ) {\n this._evictOldestStream(entry.streamId);\n }\n\n this.streams.set(entry.streamId, entry);\n }\n\n // get a stream from the registry\n get(streamId: string): StreamEntry | null {\n return this.streams.get(streamId) ?? null;\n }\n\n // check if a stream exists in the registry\n has(streamId: string): boolean {\n return this.streams.has(streamId);\n }\n\n // remove a stream from the registry\n remove(streamId: string): void {\n this.streams.delete(streamId);\n }\n\n // get the number of streams in the registry\n size(): number {\n return this.streams.size;\n }\n\n clear(): void {\n for (const stream of this.streams.values()) {\n stream.abortController.abort(\"Server shutdown\");\n clearGraceTimer(stream);\n clearRemovalTimer(stream);\n }\n\n this.streams.clear();\n }\n\n // evict the oldest stream from the registry, preferring completed streams.\n // Completed streams waiting out their buffer TTL can look recently\n // accessed, so plain LRU could evict a live stream while dead ones\n // survive. Prefer the oldest completed stream when one exists and fall\n // back to LRU over all streams otherwise.\n private _evictOldestStream(excludeStreamId: string): void {\n const allStreams = this.streams.values();\n let oldestStream: StreamEntry | null = null;\n let oldestAccess = Infinity;\n let oldestCompletedStream: StreamEntry | null = null;\n let oldestCompletedAccess = Infinity;\n\n // find the least recently accessed stream (overall and completed-only)\n for (const stream of allStreams) {\n if (stream.streamId === excludeStreamId) continue;\n\n if (stream.lastAccess < oldestAccess) {\n oldestStream = stream;\n oldestAccess = stream.lastAccess;\n }\n\n if (stream.isCompleted && stream.lastAccess < oldestCompletedAccess) {\n oldestCompletedStream = stream;\n oldestCompletedAccess = stream.lastAccess;\n }\n }\n\n oldestStream = oldestCompletedStream ?? oldestStream;\n\n // abort the evicted stream\n if (oldestStream) {\n // broadcast stream eviction error to all clients\n for (const client of oldestStream.clients) {\n if (!client.writableEnded) {\n try {\n client.write(`event: error\\n`);\n client.write(\n `data: ${JSON.stringify({ error: \"Stream evicted\", code: SSEErrorCode.STREAM_EVICTED })}\\n\\n`,\n );\n } catch (_error) {\n // ignore\n }\n }\n }\n clearGraceTimer(oldestStream);\n // abort with a DOMException AbortError so the error categorizer routes\n // eviction as an abort (matching the manager's terminal paths) rather\n // than a stream failure\n oldestStream.abortController.abort(\n new DOMException(\"Stream evicted\", \"AbortError\"),\n );\n // a pending removal timer would otherwise pin the evicted entry\n clearRemovalTimer(oldestStream);\n this.streams.delete(oldestStream.streamId);\n }\n }\n}\n"],"mappings":";;;;AAGA,IAAa,iBAAb,MAA4B;CAK1B,AAAQ;CACR,AAAQ;CAER,YAAY,kBAA0B;AACpC,OAAK,0BAAU,IAAI,KAAK;AACxB,OAAK,mBAAmB;;CAI1B,IAAI,OAA0B;AAI5B,MACE,CAAC,KAAK,QAAQ,IAAI,MAAM,SAAS,IACjC,KAAK,QAAQ,QAAQ,KAAK,iBAE1B,MAAK,mBAAmB,MAAM,SAAS;AAGzC,OAAK,QAAQ,IAAI,MAAM,UAAU,MAAM;;CAIzC,IAAI,UAAsC;AACxC,SAAO,KAAK,QAAQ,IAAI,SAAS,IAAI;;CAIvC,IAAI,UAA2B;AAC7B,SAAO,KAAK,QAAQ,IAAI,SAAS;;CAInC,OAAO,UAAwB;AAC7B,OAAK,QAAQ,OAAO,SAAS;;CAI/B,OAAe;AACb,SAAO,KAAK,QAAQ;;CAGtB,QAAc;AACZ,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,EAAE;AAC1C,UAAO,gBAAgB,MAAM,kBAAkB;AAC/C,mBAAgB,OAAO;AACvB,qBAAkB,OAAO;;AAG3B,OAAK,QAAQ,OAAO;;CAQtB,AAAQ,mBAAmB,iBAA+B;EACxD,MAAM,aAAa,KAAK,QAAQ,QAAQ;EACxC,IAAI,eAAmC;EACvC,IAAI,eAAe;EACnB,IAAI,wBAA4C;EAChD,IAAI,wBAAwB;AAG5B,OAAK,MAAM,UAAU,YAAY;AAC/B,OAAI,OAAO,aAAa,gBAAiB;AAEzC,OAAI,OAAO,aAAa,cAAc;AACpC,mBAAe;AACf,mBAAe,OAAO;;AAGxB,OAAI,OAAO,eAAe,OAAO,aAAa,uBAAuB;AACnE,4BAAwB;AACxB,4BAAwB,OAAO;;;AAInC,iBAAe,yBAAyB;AAGxC,MAAI,cAAc;AAEhB,QAAK,MAAM,UAAU,aAAa,QAChC,KAAI,CAAC,OAAO,cACV,KAAI;AACF,WAAO,MAAM,iBAAiB;AAC9B,WAAO,MACL,SAAS,KAAK,UAAU;KAAE,OAAO;KAAkB,MAAM,aAAa;KAAgB,CAAC,CAAC,MACzF;YACM,QAAQ;AAKrB,mBAAgB,aAAa;AAI7B,gBAAa,gBAAgB,MAC3B,IAAI,aAAa,kBAAkB,aAAa,CACjD;AAED,qBAAkB,aAAa;AAC/B,QAAK,QAAQ,OAAO,aAAa,SAAS"}
@@ -0,0 +1,17 @@
1
+ //#region src/stream/timers.ts
2
+ function clearGraceTimer(entry) {
3
+ if (entry.disconnectGraceTimer) {
4
+ clearTimeout(entry.disconnectGraceTimer);
5
+ entry.disconnectGraceTimer = void 0;
6
+ }
7
+ }
8
+ function clearRemovalTimer(entry) {
9
+ if (entry.removalTimer) {
10
+ clearTimeout(entry.removalTimer);
11
+ entry.removalTimer = void 0;
12
+ }
13
+ }
14
+
15
+ //#endregion
16
+ export { clearGraceTimer, clearRemovalTimer };
17
+ //# sourceMappingURL=timers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timers.js","names":[],"sources":["../../src/stream/timers.ts"],"sourcesContent":["import type { StreamEntry } from \"./types\";\n\n// clear a pending disconnect-grace timer so a removed/reconnected stream\n// isn't pinned until it fires\nexport function clearGraceTimer(entry: StreamEntry): void {\n if (entry.disconnectGraceTimer) {\n clearTimeout(entry.disconnectGraceTimer);\n entry.disconnectGraceTimer = undefined;\n }\n}\n\n// clear a pending registry-removal timer so a reconnected/evicted stream\n// isn't pulled out from under a client\nexport function clearRemovalTimer(entry: StreamEntry): void {\n if (entry.removalTimer) {\n clearTimeout(entry.removalTimer);\n entry.removalTimer = undefined;\n }\n}\n"],"mappings":";AAIA,SAAgB,gBAAgB,OAA0B;AACxD,KAAI,MAAM,sBAAsB;AAC9B,eAAa,MAAM,qBAAqB;AACxC,QAAM,uBAAuB;;;AAMjC,SAAgB,kBAAkB,OAA0B;AAC1D,KAAI,MAAM,cAAc;AACtB,eAAa,MAAM,aAAa;AAChC,QAAM,eAAe"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../src/stream/types.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type { IAppResponse } from \"shared\";\nimport type { EventRingBuffer } from \"./buffers\";\n\nexport const SSEWarningCode = {\n BUFFER_OVERFLOW_RESTART: \"BUFFER_OVERFLOW_RESTART\",\n} as const satisfies Record<string, string>;\n\nexport type SSEWarningCode =\n (typeof SSEWarningCode)[keyof typeof SSEWarningCode];\n\nexport const SSEErrorCode = {\n TEMPORARY_UNAVAILABLE: \"TEMPORARY_UNAVAILABLE\",\n TIMEOUT: \"TIMEOUT\",\n INTERNAL_ERROR: \"INTERNAL_ERROR\",\n INVALID_REQUEST: \"INVALID_REQUEST\",\n STREAM_ABORTED: \"STREAM_ABORTED\",\n STREAM_EVICTED: \"STREAM_EVICTED\",\n STREAM_FORBIDDEN: \"STREAM_FORBIDDEN\",\n UPSTREAM_ERROR: \"UPSTREAM_ERROR\",\n} as const satisfies Record<string, string>;\n\nexport type SSEErrorCode = (typeof SSEErrorCode)[keyof typeof SSEErrorCode];\n\nexport interface SSEError {\n error: string;\n code: SSEErrorCode;\n /**\n * Upstream-domain structured code (e.g. `RESULT_TOO_LARGE_FOR_JSON_FALLBACK`,\n * `NOT_IMPLEMENTED`). UI code should branch on this instead of parsing\n * the human-readable `error` string.\n */\n errorCode?: string;\n}\n\nexport interface BufferedEvent {\n id: string;\n type: string;\n data: string;\n timestamp: number;\n}\n\nexport interface StreamEntry {\n streamId: string;\n /**\n * Identifier of the principal that created the stream (e.g. end-user ID\n * or service principal user ID). When set, only requests sharing the\n * same owner key may reconnect to the stream.\n */\n ownerKey?: string;\n generator: AsyncGenerator<any, void, unknown>;\n eventBuffer: EventRingBuffer;\n clients: Set<IAppResponse>;\n isCompleted: boolean;\n lastAccess: number;\n abortController: AbortController;\n traceContext: Context;\n // pending grace-window abort, set while the last client is disconnected\n disconnectGraceTimer?: NodeJS.Timeout;\n}\n\nexport interface StreamOperation {\n controller: AbortController;\n type: \"query\" | \"stream\";\n heartbeat?: NodeJS.Timeout;\n}\n"],"mappings":";AAIA,MAAa,iBAAiB,EAC5B,yBAAyB,2BAC1B;AAKD,MAAa,eAAe;CAC1B,uBAAuB;CACvB,SAAS;CACT,gBAAgB;CAChB,iBAAiB;CACjB,gBAAgB;CAChB,gBAAgB;CAChB,kBAAkB;CAClB,gBAAgB;CACjB"}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../src/stream/types.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type { IAppResponse } from \"shared\";\nimport type { EventRingBuffer } from \"./buffers\";\n\nexport const SSEWarningCode = {\n BUFFER_OVERFLOW_RESTART: \"BUFFER_OVERFLOW_RESTART\",\n} as const satisfies Record<string, string>;\n\nexport type SSEWarningCode =\n (typeof SSEWarningCode)[keyof typeof SSEWarningCode];\n\nexport const SSEErrorCode = {\n TEMPORARY_UNAVAILABLE: \"TEMPORARY_UNAVAILABLE\",\n TIMEOUT: \"TIMEOUT\",\n INTERNAL_ERROR: \"INTERNAL_ERROR\",\n INVALID_REQUEST: \"INVALID_REQUEST\",\n STREAM_ABORTED: \"STREAM_ABORTED\",\n STREAM_EVICTED: \"STREAM_EVICTED\",\n STREAM_FORBIDDEN: \"STREAM_FORBIDDEN\",\n UPSTREAM_ERROR: \"UPSTREAM_ERROR\",\n} as const satisfies Record<string, string>;\n\nexport type SSEErrorCode = (typeof SSEErrorCode)[keyof typeof SSEErrorCode];\n\nexport interface SSEError {\n error: string;\n code: SSEErrorCode;\n /**\n * Upstream-domain structured code (e.g. `RESULT_TOO_LARGE_FOR_JSON_FALLBACK`,\n * `NOT_IMPLEMENTED`). UI code should branch on this instead of parsing\n * the human-readable `error` string.\n */\n errorCode?: string;\n}\n\nexport interface BufferedEvent {\n id: string;\n type: string;\n data: string;\n timestamp: number;\n}\n\nexport interface StreamEntry {\n streamId: string;\n /**\n * Identifier of the principal that created the stream (e.g. end-user ID\n * or service principal user ID). When set, only requests sharing the\n * same owner key may reconnect to the stream.\n */\n ownerKey?: string;\n generator: AsyncGenerator<any, void, unknown>;\n eventBuffer: EventRingBuffer;\n clients: Set<IAppResponse>;\n isCompleted: boolean;\n lastAccess: number;\n abortController: AbortController;\n traceContext: Context;\n // pending grace-window abort, set while the last client is disconnected\n disconnectGraceTimer?: NodeJS.Timeout;\n /**\n * Pending registry-removal timer. At most one removal timer exists per\n * stream; scheduling a new one clears any previous timer first.\n */\n removalTimer?: NodeJS.Timeout;\n}\n\nexport interface StreamOperation {\n controller: AbortController;\n type: \"query\" | \"stream\";\n heartbeat?: NodeJS.Timeout;\n}\n"],"mappings":";AAIA,MAAa,iBAAiB,EAC5B,yBAAyB,2BAC1B;AAKD,MAAa,eAAe;CAC1B,uBAAuB;CACvB,SAAS;CACT,gBAAgB;CAChB,iBAAiB;CACjB,gBAAgB;CAChB,gBAAgB;CAChB,kBAAkB;CAClB,gBAAgB;CACjB"}
@@ -19,6 +19,7 @@ var TelemetryManager = class TelemetryManager {
19
19
  static DEFAULT_FALLBACK_APP_NAME = "databricks-app";
20
20
  static instance;
21
21
  sdk;
22
+ shutdownPromise;
22
23
  /**
23
24
  * Create a scoped telemetry provider for a specific plugin.
24
25
  * The plugin's name will be used as the default tracer/meter name.
@@ -54,7 +55,6 @@ var TelemetryManager = class TelemetryManager {
54
55
  instrumentations: this.getDefaultInstrumentations()
55
56
  });
56
57
  this.sdk.start();
57
- this.registerShutdown();
58
58
  logger.debug("Initialized successfully");
59
59
  } catch (error) {
60
60
  logger.error("Failed to initialize: %O", error);
@@ -90,21 +90,27 @@ var TelemetryManager = class TelemetryManager {
90
90
  "@opentelemetry/instrumentation-net": { enabled: false }
91
91
  })];
92
92
  }
93
- registerShutdown() {
94
- const shutdownFn = async () => {
95
- await TelemetryManager.getInstance().shutdown();
96
- };
97
- process.once("SIGTERM", shutdownFn);
98
- process.once("SIGINT", shutdownFn);
99
- }
93
+ /**
94
+ * Flush and shut down the OpenTelemetry SDK.
95
+ *
96
+ * Idempotent: the SDK reference is cleared synchronously and concurrent
97
+ * or repeated calls await the same in-flight flush. Awaited by the core
98
+ * lifecycle manager during graceful shutdown — that manager owns the
99
+ * process signal handlers, so telemetry no longer registers its own.
100
+ */
100
101
  async shutdown() {
101
- if (!this.sdk) return;
102
- try {
103
- await this.sdk.shutdown();
102
+ if (this.sdk) {
103
+ const sdk = this.sdk;
104
104
  this.sdk = void 0;
105
- } catch (error) {
106
- logger.error("Error shutting down: %O", error);
105
+ this.shutdownPromise = (async () => {
106
+ try {
107
+ await sdk.shutdown();
108
+ } catch (error) {
109
+ logger.error("Error shutting down: %O", error);
110
+ }
111
+ })();
107
112
  }
113
+ return this.shutdownPromise;
108
114
  }
109
115
  };
110
116
 
@@ -1 +1 @@
1
- {"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { 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 { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\";\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\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\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 sdk?: NodeSDK;\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 private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.sdk) return;\n\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.sdk = new NodeSDK({\n resource: this.createResource(config),\n autoDetectResources: false,\n sampler: new AppKitSampler(),\n traceExporter: new OTLPTraceExporter({ headers: config.headers }),\n metricReaders: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n logRecordProcessors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n instrumentations: this.getDefaultInstrumentations(),\n });\n\n this.sdk.start();\n this.registerShutdown();\n logger.debug(\"Initialized successfully\");\n } catch (error) {\n logger.error(\"Failed to initialize: %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 // global providers set by NodeSDK.start()\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 private registerShutdown() {\n const shutdownFn = async () => {\n await TelemetryManager.getInstance().shutdown();\n };\n process.once(\"SIGTERM\", shutdownFn);\n process.once(\"SIGINT\", shutdownFn);\n }\n\n private async shutdown(): Promise<void> {\n if (!this.sdk) {\n return;\n }\n\n try {\n await this.sdk.shutdown();\n this.sdk = undefined;\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,MAAM,SAAS,aAAa,YAAY;AAExC,IAAa,mBAAb,MAAa,iBAAiB;CAC5B,OAAwB,6BAA6B;CACrD,OAAwB,4BAA4B;CAEpD,OAAe;CACf,AAAQ;;;;;;;;CASR,OAAO,YACL,YACA,iBACmB;AAEnB,SAAO,IAAI,kBAAkB,YADP,iBAAiB,aAAa,EACI,gBAAgB;;CAG1E,AAAQ,cAAc;CAEtB,OAAO,cAAgC;AACrC,MAAI,CAAC,iBAAiB,SACpB,kBAAiB,WAAW,IAAI,kBAAkB;AAEpD,SAAO,iBAAiB;;CAG1B,OAAO,WAAW,SAAmC,EAAE,EAAQ;AAE7D,EADiB,iBAAiB,aAAa,CACtC,YAAY,OAAO;;CAG9B,AAAQ,YAAY,QAAwC;AAC1D,MAAI,KAAK,IAAK;AAEd,MAAI,CAAC,QAAQ,IAAI,4BACf;AAGF,MAAI;AACF,QAAK,MAAM,IAAI,QAAQ;IACrB,UAAU,KAAK,eAAe,OAAO;IACrC,qBAAqB;IACrB,SAAS,IAAI,eAAe;IAC5B,eAAe,IAAI,kBAAkB,EAAE,SAAS,OAAO,SAAS,CAAC;IACjE,eAAe,CACb,IAAI,8BAA8B;KAChC,UAAU,IAAI,mBAAmB,EAAE,SAAS,OAAO,SAAS,CAAC;KAC7D,sBACE,OAAO,oBACP,iBAAiB;KACpB,CAAC,CACH;IACD,qBAAqB,CACnB,IAAI,wBACF,IAAI,gBAAgB,EAAE,SAAS,OAAO,SAAS,CAAC,CACjD,CACF;IACD,kBAAkB,KAAK,4BAA4B;IACpD,CAAC;AAEF,QAAK,IAAI,OAAO;AAChB,QAAK,kBAAkB;AACvB,UAAO,MAAM,2BAA2B;WACjC,OAAO;AACd,UAAO,MAAM,4BAA4B,MAAM;;;;;;;;CASnD,yBAAyB,kBAA2C;AAClE,2BAA6B,EAE3B,kBACD,CAAC;;CAGJ,AAAQ,eAAe,QAA4C;EACjE,MAAM,cACJ,OAAO,eACP,QAAQ,IAAI,qBACZ,QAAQ,IAAI,uBACZ,iBAAiB;EACnB,MAAM,kBAAkB,uBAAuB;IAC5C,oBAAoB;IACpB,uBAAuB,OAAO,kBAAkB;GAClD,CAAC;EACF,MAAM,mBAAmB,gBAAgB,EACvC,WAAW;GAAC;GAAa;GAAc;GAAgB,EACxD,CAAC;AACF,SAAO,gBAAgB,MAAM,iBAAiB;;CAGhD,AAAQ,6BAAgD;AACtD,SAAO,CACL,GAAG,4BAA4B;GAI7B,uCAAuC,EACrC,SAAS,OACV;GACD,0CAA0C,EACxC,SAAS,OACV;GAID,qCAAqC,EACnC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACF,CAAC,CACH;;CAGH,AAAQ,mBAAmB;EACzB,MAAM,aAAa,YAAY;AAC7B,SAAM,iBAAiB,aAAa,CAAC,UAAU;;AAEjD,UAAQ,KAAK,WAAW,WAAW;AACnC,UAAQ,KAAK,UAAU,WAAW;;CAGpC,MAAc,WAA0B;AACtC,MAAI,CAAC,KAAK,IACR;AAGF,MAAI;AACF,SAAM,KAAK,IAAI,UAAU;AACzB,QAAK,MAAM;WACJ,OAAO;AACd,UAAO,MAAM,2BAA2B,MAAM"}
1
+ {"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { 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 { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\";\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\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\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 sdk?: NodeSDK;\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 private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.sdk) return;\n\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.sdk = new NodeSDK({\n resource: this.createResource(config),\n autoDetectResources: false,\n sampler: new AppKitSampler(),\n traceExporter: new OTLPTraceExporter({ headers: config.headers }),\n metricReaders: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n logRecordProcessors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n instrumentations: this.getDefaultInstrumentations(),\n });\n\n this.sdk.start();\n logger.debug(\"Initialized successfully\");\n } catch (error) {\n logger.error(\"Failed to initialize: %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 // global providers set by NodeSDK.start()\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 OpenTelemetry SDK.\n *\n * Idempotent: the SDK reference is 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 if (this.sdk) {\n const sdk = this.sdk;\n this.sdk = undefined;\n this.shutdownPromise = (async () => {\n try {\n await sdk.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n })();\n }\n\n return this.shutdownPromise;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,MAAM,SAAS,aAAa,YAAY;AAExC,IAAa,mBAAb,MAAa,iBAAiB;CAC5B,OAAwB,6BAA6B;CACrD,OAAwB,4BAA4B;CAEpD,OAAe;CACf,AAAQ;CACR,AAAQ;;;;;;;;CASR,OAAO,YACL,YACA,iBACmB;AAEnB,SAAO,IAAI,kBAAkB,YADP,iBAAiB,aAAa,EACI,gBAAgB;;CAG1E,AAAQ,cAAc;CAEtB,OAAO,cAAgC;AACrC,MAAI,CAAC,iBAAiB,SACpB,kBAAiB,WAAW,IAAI,kBAAkB;AAEpD,SAAO,iBAAiB;;CAG1B,OAAO,WAAW,SAAmC,EAAE,EAAQ;AAE7D,EADiB,iBAAiB,aAAa,CACtC,YAAY,OAAO;;CAG9B,AAAQ,YAAY,QAAwC;AAC1D,MAAI,KAAK,IAAK;AAEd,MAAI,CAAC,QAAQ,IAAI,4BACf;AAGF,MAAI;AACF,QAAK,MAAM,IAAI,QAAQ;IACrB,UAAU,KAAK,eAAe,OAAO;IACrC,qBAAqB;IACrB,SAAS,IAAI,eAAe;IAC5B,eAAe,IAAI,kBAAkB,EAAE,SAAS,OAAO,SAAS,CAAC;IACjE,eAAe,CACb,IAAI,8BAA8B;KAChC,UAAU,IAAI,mBAAmB,EAAE,SAAS,OAAO,SAAS,CAAC;KAC7D,sBACE,OAAO,oBACP,iBAAiB;KACpB,CAAC,CACH;IACD,qBAAqB,CACnB,IAAI,wBACF,IAAI,gBAAgB,EAAE,SAAS,OAAO,SAAS,CAAC,CACjD,CACF;IACD,kBAAkB,KAAK,4BAA4B;IACpD,CAAC;AAEF,QAAK,IAAI,OAAO;AAChB,UAAO,MAAM,2BAA2B;WACjC,OAAO;AACd,UAAO,MAAM,4BAA4B,MAAM;;;;;;;;CASnD,yBAAyB,kBAA2C;AAClE,2BAA6B,EAE3B,kBACD,CAAC;;CAGJ,AAAQ,eAAe,QAA4C;EACjE,MAAM,cACJ,OAAO,eACP,QAAQ,IAAI,qBACZ,QAAQ,IAAI,uBACZ,iBAAiB;EACnB,MAAM,kBAAkB,uBAAuB;IAC5C,oBAAoB;IACpB,uBAAuB,OAAO,kBAAkB;GAClD,CAAC;EACF,MAAM,mBAAmB,gBAAgB,EACvC,WAAW;GAAC;GAAa;GAAc;GAAgB,EACxD,CAAC;AACF,SAAO,gBAAgB,MAAM,iBAAiB;;CAGhD,AAAQ,6BAAgD;AACtD,SAAO,CACL,GAAG,4BAA4B;GAI7B,uCAAuC,EACrC,SAAS,OACV;GACD,0CAA0C,EACxC,SAAS,OACV;GAID,qCAAqC,EACnC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACF,CAAC,CACH;;;;;;;;;;CAWH,MAAM,WAA0B;AAC9B,MAAI,KAAK,KAAK;GACZ,MAAM,MAAM,KAAK;AACjB,QAAK,MAAM;AACX,QAAK,mBAAmB,YAAY;AAClC,QAAI;AACF,WAAM,IAAI,UAAU;aACb,OAAO;AACd,YAAO,MAAM,2BAA2B,MAAM;;OAE9C;;AAGN,SAAO,KAAK"}
@@ -179,9 +179,15 @@ async function probeWarehouseState(getClient, warehouseId) {
179
179
  * only when the warehouse is already RUNNING, otherwise emits permissive
180
180
  * degraded types immediately. `"blocking"` waits for / starts the warehouse
181
181
  * first, failing the build only for a deleted/deleting one.
182
+ * @param options.metricViewsFolder - folder that holds `definitions.json`
183
+ * (`<root>/config/metric-views`). Optional and independent of `queryFolder`:
184
+ * metric-view types generate whenever this folder holds a config, even if the
185
+ * app has no `config/queries`. When omitted it defaults to a sibling
186
+ * `metric-views` directory of `queryFolder` (so query-only callers keep
187
+ * working); when neither is given, the metric path is skipped.
182
188
  * @param options.mvOutFile - optional output file for the MetricRegistry
183
189
  * augmentation. Defaults to a sibling `metric-views.d.ts` file under the same
184
- * directory as `outFile`. Skipped entirely if `metric-views.json` is absent.
190
+ * directory as `outFile`. Skipped entirely if `definitions.json` is absent.
185
191
  * @param options.metricFetcher - optional DescribeFetcher used by
186
192
  * {@link syncMetrics} (tests inject a mock; production lazily builds a
187
193
  * default WorkspaceClient-backed one). An injected fetcher always runs: it
@@ -190,6 +196,7 @@ async function probeWarehouseState(getClient, warehouseId) {
190
196
  */
191
197
  async function generateFromEntryPoint(options) {
192
198
  const { outFile, queryFolder, warehouseId, noCache, mode = "non-blocking", mvOutFile, metricFetcher } = options;
199
+ const metricViewsFolder = options.metricViewsFolder ?? (queryFolder ? path.resolve(queryFolder, "..", "metric-views") : void 0);
193
200
  const projectRoot = resolveProjectRoot(outFile);
194
201
  logger.debug("Starting type generation...");
195
202
  let queryRegistry = [];
@@ -207,12 +214,12 @@ async function generateFromEntryPoint(options) {
207
214
  const typeDeclarations = generateTypeDeclarations(queryRegistry);
208
215
  await fs.mkdir(path.dirname(outFile), { recursive: true });
209
216
  await fs.writeFile(outFile, typeDeclarations, "utf-8");
210
- if (queryFolder) {
217
+ if (metricViewsFolder) {
211
218
  const mvFile = mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE);
212
219
  let mvResult;
213
220
  try {
214
221
  mvResult = await syncMetricViewsTypes({
215
- queryFolder,
222
+ metricViewsFolder,
216
223
  warehouseId,
217
224
  metricOutFile: mvFile,
218
225
  cache: !noCache,
@@ -221,7 +228,7 @@ async function generateFromEntryPoint(options) {
221
228
  });
222
229
  } catch (configError) {
223
230
  throw new TypegenFatalError([{
224
- name: "metric-views.json",
231
+ name: "config/metric-views/definitions.json",
225
232
  message: getErrorDiagnostic(configError)
226
233
  }], warehouseId);
227
234
  }
@@ -244,7 +251,7 @@ async function generateFromEntryPoint(options) {
244
251
  * `"describe-now"` mode for a focused, always-converge metric refresh.
245
252
  *
246
253
  *
247
- * @param options.queryFolder - folder that holds `metric-views.json` (`<root>/config/queries`).
254
+ * @param options.metricViewsFolder - folder that holds `definitions.json` (`<root>/config/metric-views`).
248
255
  * @param options.warehouseId - SQL warehouse used for `DESCRIBE TABLE EXTENDED`.
249
256
  * @param options.metricOutFile - output path for the MetricRegistry `.d.ts`.
250
257
  * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching).
@@ -252,9 +259,9 @@ async function generateFromEntryPoint(options) {
252
259
  * @param options.mode - preflight/gate policy, default `"describe-now"`.
253
260
  */
254
261
  async function syncMetricViewsTypes(options) {
255
- const { queryFolder, warehouseId, metricOutFile, cache: cacheEnabled, metricFetcher, mode = "describe-now" } = options;
262
+ const { metricViewsFolder, warehouseId, metricOutFile, cache: cacheEnabled, metricFetcher, mode = "describe-now" } = options;
256
263
  const noCache = cacheEnabled === false;
257
- const mvConfig = await readMetricConfig(queryFolder);
264
+ const mvConfig = await readMetricConfig(metricViewsFolder);
258
265
  if (!mvConfig) return {
259
266
  schemas: [],
260
267
  failures: [],