@codemation/host 0.2.5 → 0.3.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 (109) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/{AppConfigFactory-CqKWXqOm.js → AppConfigFactory-BPp02HMv.js} +82 -5
  3. package/dist/{AppConfigFactory-CqKWXqOm.js.map → AppConfigFactory-BPp02HMv.js.map} +1 -1
  4. package/dist/{AppConfigFactory-CK28UPK0.d.ts → AppConfigFactory-Dq7ttwQ_.d.ts} +6001 -144
  5. package/dist/{AppContainerFactory-CcSGFNLW.js → AppContainerFactory-D9je1sSV.js} +2604 -155
  6. package/dist/AppContainerFactory-D9je1sSV.js.map +1 -0
  7. package/dist/{CodemationAppContext-KqDoeHqN.d.ts → CodemationAppContext-P7P-xZhQ.d.ts} +2 -2
  8. package/dist/{CodemationAuthoring.types-BP6Inucu.d.ts → CodemationAuthoring.types-OMYu7vKP.d.ts} +3 -3
  9. package/dist/{CodemationConfigNormalizer-DIAE0VHw.d.ts → CodemationConfigNormalizer-BCtBrJDe.d.ts} +2 -2
  10. package/dist/{CodemationConsumerConfigLoader-nj9kTmfJ.d.ts → CodemationConsumerConfigLoader-evvw4b_a.d.ts} +2 -2
  11. package/dist/CodemationPluginListMerger-PSTtEQjC.d.ts +674 -0
  12. package/dist/{CredentialServices-BFQD_VN1.d.ts → CredentialServices-0Hk8RFY1.d.ts} +3 -3
  13. package/dist/{CredentialServices-BPKUF8Xs.js → CredentialServices-BNBMFOPt.js} +6 -1
  14. package/dist/CredentialServices-BNBMFOPt.js.map +1 -0
  15. package/dist/{PublicFrontendBootstrapFactory-DTA1iDo0.d.ts → PublicFrontendBootstrapFactory-D0_ds7nS.d.ts} +2 -2
  16. package/dist/authoring.d.ts +3 -3
  17. package/dist/consumer.d.ts +4 -4
  18. package/dist/credentials.d.ts +3 -3
  19. package/dist/credentials.js +1 -1
  20. package/dist/devServerSidecar.d.ts +1 -1
  21. package/dist/{index-Dd6BrWyH.d.ts → index-CeS2saCe.d.ts} +105 -2
  22. package/dist/index.d.ts +12 -11
  23. package/dist/index.js +5 -5
  24. package/dist/nextServer.d.ts +8 -58
  25. package/dist/nextServer.js +6 -100
  26. package/dist/{persistenceServer-DKbFDxoS.js → persistenceServer-CA0_q0D7.js} +2 -2
  27. package/dist/{persistenceServer-DKbFDxoS.js.map → persistenceServer-CA0_q0D7.js.map} +1 -1
  28. package/dist/{persistenceServer-Y-u7lV7f.d.ts → persistenceServer-CJeu1STC.d.ts} +2 -2
  29. package/dist/persistenceServer.d.ts +5 -5
  30. package/dist/persistenceServer.js +2 -2
  31. package/dist/{server-CFpgKuVE.js → server-C_ZIEOTY.js} +4 -4
  32. package/dist/{server-CFpgKuVE.js.map → server-C_ZIEOTY.js.map} +1 -1
  33. package/dist/{server-axppTMgo.d.ts → server-Clvg5x1w.d.ts} +11 -5
  34. package/dist/server.d.ts +8 -8
  35. package/dist/server.js +5 -5
  36. package/package.json +6 -5
  37. package/prisma/migrations/20260414120000_telemetry_foundation/migration.sql +112 -0
  38. package/prisma/migrations/20260414153000_telemetry_retention_metrics_refactor/migration.sql +239 -0
  39. package/prisma/migrations.sqlite/20260414120000_telemetry_foundation/migration.sql +103 -0
  40. package/prisma/migrations.sqlite/20260414153000_telemetry_retention_metrics_refactor/migration.sql +540 -0
  41. package/prisma/schema.postgresql.prisma +100 -1
  42. package/prisma/schema.sqlite.prisma +100 -1
  43. package/scripts/generate-prisma-clients.mjs +89 -1
  44. package/src/application/contracts/TelemetryDashboardContracts.ts +113 -0
  45. package/src/application/contracts/TelemetryRunTraceContracts.ts +13 -0
  46. package/src/application/cost/FrameworkCostCatalogEntries.ts +126 -0
  47. package/src/application/queries/GetTelemetryDashboardDimensionsQuery.ts +11 -0
  48. package/src/application/queries/GetTelemetryDashboardDimensionsQueryHandler.ts +20 -0
  49. package/src/application/queries/GetTelemetryDashboardRunsQuery.ts +11 -0
  50. package/src/application/queries/GetTelemetryDashboardRunsQueryHandler.ts +20 -0
  51. package/src/application/queries/GetTelemetryDashboardSummaryQuery.ts +11 -0
  52. package/src/application/queries/GetTelemetryDashboardSummaryQueryHandler.ts +29 -0
  53. package/src/application/queries/GetTelemetryDashboardTimeseriesQuery.ts +11 -0
  54. package/src/application/queries/GetTelemetryDashboardTimeseriesQueryHandler.ts +36 -0
  55. package/src/application/queries/GetTelemetryRunTraceQuery.ts +8 -0
  56. package/src/application/queries/GetTelemetryRunTraceQueryHandler.ts +20 -0
  57. package/src/application/queries/WorkflowQueryHandlers.ts +5 -0
  58. package/src/application/runs/WorkflowRunRetentionPruneScheduler.ts +71 -26
  59. package/src/application/telemetry/CompositeTelemetryExporter.ts +13 -0
  60. package/src/application/telemetry/LazyExecutionTelemetryFactory.ts +21 -0
  61. package/src/application/telemetry/NoOpTelemetryExporter.ts +7 -0
  62. package/src/application/telemetry/OtelExecutionTelemetry.types.ts +41 -0
  63. package/src/application/telemetry/OtelExecutionTelemetryFactory.ts +56 -0
  64. package/src/application/telemetry/OtelIdentityFactory.ts +41 -0
  65. package/src/application/telemetry/RunEventBusTelemetryReporter.ts +188 -0
  66. package/src/application/telemetry/StoredExecutionTelemetry.ts +56 -0
  67. package/src/application/telemetry/StoredNodeExecutionTelemetry.ts +35 -0
  68. package/src/application/telemetry/StoredTelemetrySpanScope.ts +188 -0
  69. package/src/application/telemetry/TelemetryEnricherChain.ts +85 -0
  70. package/src/application/telemetry/TelemetryPrivacyPolicy.ts +19 -0
  71. package/src/application/telemetry/TelemetryQueryService.ts +815 -0
  72. package/src/application/telemetry/TelemetryRetentionTimestampFactory.ts +40 -0
  73. package/src/applicationTokens.ts +18 -0
  74. package/src/bootstrap/AppContainerFactory.ts +124 -1
  75. package/src/bootstrap/AppContainerLifecycle.ts +8 -0
  76. package/src/bootstrap/runtime/FrontendRuntime.ts +8 -0
  77. package/src/bootstrap/runtime/WorkerRuntime.ts +8 -0
  78. package/src/domain/runs/WorkflowRunRepository.ts +3 -1
  79. package/src/domain/telemetry/TelemetryContracts.ts +197 -0
  80. package/src/infrastructure/persistence/InMemoryRunTraceContextRepository.ts +56 -0
  81. package/src/infrastructure/persistence/InMemoryTelemetryArtifactStore.ts +56 -0
  82. package/src/infrastructure/persistence/InMemoryTelemetryMetricPointStore.ts +97 -0
  83. package/src/infrastructure/persistence/InMemoryTelemetrySpanStore.ts +113 -0
  84. package/src/infrastructure/persistence/InMemoryWorkflowRunRepository.ts +4 -2
  85. package/src/infrastructure/persistence/PrismaRunTraceContextRepository.ts +92 -0
  86. package/src/infrastructure/persistence/PrismaTelemetryArtifactStore.ts +125 -0
  87. package/src/infrastructure/persistence/PrismaTelemetryMetricPointStore.ts +134 -0
  88. package/src/infrastructure/persistence/PrismaTelemetrySpanStore.ts +166 -0
  89. package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +20 -7
  90. package/src/infrastructure/persistence/generated/prisma-postgresql-client/edge.js +85 -4
  91. package/src/infrastructure/persistence/generated/prisma-postgresql-client/index-browser.js +81 -0
  92. package/src/infrastructure/persistence/generated/prisma-postgresql-client/index.d.ts +6488 -102
  93. package/src/infrastructure/persistence/generated/prisma-postgresql-client/index.js +85 -4
  94. package/src/infrastructure/persistence/generated/prisma-postgresql-client/package.json +1 -1
  95. package/src/infrastructure/persistence/generated/prisma-postgresql-client/schema.prisma +100 -0
  96. package/src/infrastructure/persistence/generated/prisma-sqlite-client/edge.js +85 -4
  97. package/src/infrastructure/persistence/generated/prisma-sqlite-client/index-browser.js +81 -0
  98. package/src/infrastructure/persistence/generated/prisma-sqlite-client/index.d.ts +6476 -98
  99. package/src/infrastructure/persistence/generated/prisma-sqlite-client/index.js +85 -4
  100. package/src/infrastructure/persistence/generated/prisma-sqlite-client/package.json +1 -1
  101. package/src/infrastructure/persistence/generated/prisma-sqlite-client/schema.prisma +100 -0
  102. package/src/presentation/http/ApiPaths.ts +22 -0
  103. package/src/presentation/http/hono/registrars/TelemetryHonoApiRouteRegistrar.ts +19 -0
  104. package/src/presentation/http/routeHandlers/TelemetryDashboardRequestError.ts +1 -0
  105. package/src/presentation/http/routeHandlers/TelemetryHttpRouteHandler.ts +181 -0
  106. package/dist/AppContainerFactory-CcSGFNLW.js.map +0 -1
  107. package/dist/CodemationPluginListMerger-QvUa2SIt.d.ts +0 -357
  108. package/dist/CredentialServices-BPKUF8Xs.js.map +0 -1
  109. package/dist/nextServer.js.map +0 -1
@@ -0,0 +1,815 @@
1
+ import {
2
+ CostTrackingTelemetryAttributeNames,
3
+ CostTrackingTelemetryMetricNames,
4
+ GenAiTelemetryAttributeNames,
5
+ inject,
6
+ injectable,
7
+ } from "@codemation/core";
8
+ import type {
9
+ TelemetryDashboardCostAggregateDto,
10
+ TelemetryDashboardCostCurrencyTotalDto,
11
+ TelemetryDashboardCostKeyTotalDto,
12
+ TelemetryDashboardBucketIntervalDto,
13
+ TelemetryDashboardBucketCostDto,
14
+ TelemetryDashboardDimensionsDto,
15
+ TelemetryDashboardRunOriginDto,
16
+ TelemetryDashboardRunsDto,
17
+ TelemetryDashboardRunsRequestDto,
18
+ TelemetryDashboardTimeseriesBucketDto,
19
+ TelemetryDashboardTimeseriesDto,
20
+ } from "../contracts/TelemetryDashboardContracts";
21
+ import type { TelemetryRunTraceViewDto } from "../contracts/TelemetryRunTraceContracts";
22
+ import { ApplicationTokens } from "../../applicationTokens";
23
+ import type {
24
+ RunTraceContextRepository,
25
+ TelemetryArtifactStore,
26
+ TelemetryMetricPointRecord,
27
+ TelemetryMetricPointStore,
28
+ TelemetrySpanRecord,
29
+ TelemetrySpanStatus,
30
+ TelemetrySpanStore,
31
+ } from "../../domain/telemetry/TelemetryContracts";
32
+ import type { WorkflowRunRepository } from "../../domain/runs/WorkflowRunRepository";
33
+ import { OtelIdentityFactory } from "./OtelIdentityFactory";
34
+
35
+ export interface TelemetryAggregateFilters {
36
+ readonly workflowId?: string;
37
+ readonly workflowIds?: ReadonlyArray<string>;
38
+ readonly statuses?: ReadonlyArray<TelemetrySpanStatus>;
39
+ readonly runOrigins?: ReadonlyArray<TelemetryDashboardRunOriginDto>;
40
+ readonly modelNames?: ReadonlyArray<string>;
41
+ readonly startTimeGte?: string;
42
+ readonly endTimeLte?: string;
43
+ }
44
+
45
+ export interface TelemetryRunAggregate {
46
+ readonly totalRuns: number;
47
+ readonly completedRuns: number;
48
+ readonly failedRuns: number;
49
+ readonly runningRuns: number;
50
+ readonly averageDurationMs: number;
51
+ }
52
+
53
+ export interface TelemetryAiAggregate {
54
+ readonly inputTokens: number;
55
+ readonly outputTokens: number;
56
+ readonly totalTokens: number;
57
+ readonly cachedInputTokens: number;
58
+ readonly reasoningTokens: number;
59
+ }
60
+
61
+ export interface TelemetryCostCurrencyAggregate {
62
+ readonly currency: string;
63
+ readonly currencyScale: number;
64
+ readonly estimatedCostMinor: number;
65
+ readonly averageCostPerRunMinor: number;
66
+ readonly costKeys: ReadonlyArray<TelemetryDashboardCostKeyTotalDto>;
67
+ }
68
+
69
+ export interface TelemetryCostAggregate {
70
+ readonly currencies: ReadonlyArray<TelemetryCostCurrencyAggregate>;
71
+ }
72
+
73
+ @injectable()
74
+ export class TelemetryQueryService {
75
+ private static readonly workflowRunSpanName = "workflow.run";
76
+ private static readonly queryScanLimit = 50_000;
77
+ private static readonly maxBucketCount = 180;
78
+
79
+ constructor(
80
+ @inject(ApplicationTokens.TelemetrySpanStore)
81
+ private readonly telemetrySpanStore: TelemetrySpanStore,
82
+ @inject(ApplicationTokens.TelemetryArtifactStore)
83
+ private readonly telemetryArtifactStore: TelemetryArtifactStore,
84
+ @inject(ApplicationTokens.TelemetryMetricPointStore)
85
+ private readonly telemetryMetricPointStore: TelemetryMetricPointStore,
86
+ @inject(ApplicationTokens.WorkflowRunRepository)
87
+ private readonly workflowRunRepository: WorkflowRunRepository,
88
+ @inject(ApplicationTokens.RunTraceContextRepository)
89
+ private readonly runTraceContextRepository: RunTraceContextRepository,
90
+ @inject(OtelIdentityFactory)
91
+ private readonly otelIdentityFactory: OtelIdentityFactory,
92
+ ) {}
93
+
94
+ async summarizeRuns(filters: TelemetryAggregateFilters = {}): Promise<TelemetryRunAggregate> {
95
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
96
+ const totalDurationMs = spans.reduce((sum, span) => sum + this.durationMs(span), 0);
97
+ const totalRuns = spans.length;
98
+ return {
99
+ totalRuns,
100
+ completedRuns: spans.filter((span) => span.status === "completed").length,
101
+ failedRuns: spans.filter((span) => span.status === "failed").length,
102
+ runningRuns: spans.filter((span) => span.status === "running").length,
103
+ averageDurationMs: totalRuns === 0 ? 0 : Math.round(totalDurationMs / totalRuns),
104
+ };
105
+ }
106
+
107
+ async summarizeAiUsage(filters: TelemetryAggregateFilters = {}): Promise<TelemetryAiAggregate> {
108
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
109
+ if (spans.length === 0) {
110
+ return {
111
+ inputTokens: 0,
112
+ outputTokens: 0,
113
+ totalTokens: 0,
114
+ cachedInputTokens: 0,
115
+ reasoningTokens: 0,
116
+ };
117
+ }
118
+ const points = await this.listAiMetricPoints(
119
+ filters,
120
+ spans.map((span) => span.runId),
121
+ );
122
+ return {
123
+ inputTokens: this.sumMetric(points, GenAiTelemetryAttributeNames.usageInputTokens),
124
+ outputTokens: this.sumMetric(points, GenAiTelemetryAttributeNames.usageOutputTokens),
125
+ totalTokens: this.sumMetric(points, GenAiTelemetryAttributeNames.usageTotalTokens),
126
+ cachedInputTokens: this.sumMetric(points, GenAiTelemetryAttributeNames.usageCacheReadInputTokens),
127
+ reasoningTokens: this.sumMetric(points, GenAiTelemetryAttributeNames.usageReasoningTokens),
128
+ };
129
+ }
130
+
131
+ async summarizeCosts(filters: TelemetryAggregateFilters = {}): Promise<TelemetryDashboardCostAggregateDto> {
132
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
133
+ if (spans.length === 0) {
134
+ return { currencies: [] };
135
+ }
136
+ const costModelNamesBySpanId = await this.loadCostModelNamesBySpanId(
137
+ filters,
138
+ spans.map((span) => span.runId),
139
+ );
140
+ const points = await this.listCostMetricPoints(
141
+ filters,
142
+ spans.map((span) => span.runId),
143
+ );
144
+ return {
145
+ currencies: this.buildCostCurrencyTotals(points, spans.length, costModelNamesBySpanId),
146
+ };
147
+ }
148
+
149
+ async summarizeRunsTimeseries(
150
+ filters: TelemetryAggregateFilters,
151
+ interval: TelemetryDashboardBucketIntervalDto,
152
+ ): Promise<TelemetryDashboardTimeseriesDto> {
153
+ const buckets = this.createBuckets(filters, interval);
154
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
155
+ for (const span of spans) {
156
+ const bucket = this.findBucket(buckets, span.endTime ?? span.startTime);
157
+ if (!bucket) {
158
+ continue;
159
+ }
160
+ bucket.totalRuns += 1;
161
+ if (span.status === "completed") {
162
+ bucket.completedRuns += 1;
163
+ } else if (span.status === "failed") {
164
+ bucket.failedRuns += 1;
165
+ } else if (span.status === "running") {
166
+ bucket.runningRuns += 1;
167
+ }
168
+ bucket.averageDurationMs += this.durationMs(span);
169
+ bucket.durationSamples += 1;
170
+ }
171
+ return {
172
+ interval,
173
+ buckets: buckets.map((bucket) => this.toTimeseriesBucketDto(bucket)),
174
+ };
175
+ }
176
+
177
+ async summarizeAiUsageTimeseries(
178
+ filters: TelemetryAggregateFilters,
179
+ interval: TelemetryDashboardBucketIntervalDto,
180
+ ): Promise<TelemetryDashboardTimeseriesDto> {
181
+ const buckets = this.createBuckets(filters, interval);
182
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
183
+ if (spans.length === 0) {
184
+ return {
185
+ interval,
186
+ buckets: buckets.map((bucket) => this.toTimeseriesBucketDto(bucket)),
187
+ };
188
+ }
189
+ const points = await this.listAiMetricPoints(
190
+ filters,
191
+ spans.map((span) => span.runId),
192
+ );
193
+ for (const point of points) {
194
+ const bucket = this.findBucket(buckets, point.observedAt);
195
+ if (!bucket) {
196
+ continue;
197
+ }
198
+ if (point.metricName === GenAiTelemetryAttributeNames.usageInputTokens) {
199
+ bucket.inputTokens += point.value;
200
+ } else if (point.metricName === GenAiTelemetryAttributeNames.usageOutputTokens) {
201
+ bucket.outputTokens += point.value;
202
+ } else if (point.metricName === GenAiTelemetryAttributeNames.usageTotalTokens) {
203
+ bucket.totalTokens += point.value;
204
+ } else if (point.metricName === GenAiTelemetryAttributeNames.usageCacheReadInputTokens) {
205
+ bucket.cachedInputTokens += point.value;
206
+ } else if (point.metricName === GenAiTelemetryAttributeNames.usageReasoningTokens) {
207
+ bucket.reasoningTokens += point.value;
208
+ }
209
+ }
210
+ return {
211
+ interval,
212
+ buckets: buckets.map((bucket) => this.toTimeseriesBucketDto(bucket)),
213
+ };
214
+ }
215
+
216
+ async summarizeCostsTimeseries(
217
+ filters: TelemetryAggregateFilters,
218
+ interval: TelemetryDashboardBucketIntervalDto,
219
+ ): Promise<TelemetryDashboardTimeseriesDto> {
220
+ const buckets = this.createBuckets(filters, interval);
221
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
222
+ if (spans.length === 0) {
223
+ return {
224
+ interval,
225
+ buckets: buckets.map((bucket) => this.toTimeseriesBucketDto(bucket)),
226
+ };
227
+ }
228
+ const costModelNamesBySpanId = await this.loadCostModelNamesBySpanId(
229
+ filters,
230
+ spans.map((span) => span.runId),
231
+ );
232
+ const points = await this.listCostMetricPoints(
233
+ filters,
234
+ spans.map((span) => span.runId),
235
+ );
236
+ for (const point of points) {
237
+ const bucket = this.findBucket(buckets, point.observedAt);
238
+ if (!bucket) {
239
+ continue;
240
+ }
241
+ this.addCostPointToBucket(bucket, point, costModelNamesBySpanId);
242
+ }
243
+ return {
244
+ interval,
245
+ buckets: buckets.map((bucket) => this.toTimeseriesBucketDto(bucket)),
246
+ };
247
+ }
248
+
249
+ async listModelNames(filters: TelemetryAggregateFilters = {}): Promise<TelemetryDashboardDimensionsDto> {
250
+ const spans = await this.resolveFilteredWorkflowRunSpans(filters);
251
+ if (spans.length === 0) {
252
+ return { modelNames: [] };
253
+ }
254
+ const modelSpans = await this.telemetrySpanStore.list({
255
+ workflowId: filters.workflowId,
256
+ workflowIds: filters.workflowIds,
257
+ runIds: [...new Set(spans.map((span) => span.runId))],
258
+ startTimeGte: filters.startTimeGte,
259
+ endTimeLte: filters.endTimeLte,
260
+ limit: TelemetryQueryService.queryScanLimit + 1,
261
+ });
262
+ this.throwWhenQueryLimitExceeded(modelSpans.length);
263
+ return {
264
+ modelNames: [...new Set(modelSpans.flatMap((span) => (span.modelName ? [span.modelName] : [])))].sort((a, b) =>
265
+ a.localeCompare(b),
266
+ ),
267
+ };
268
+ }
269
+
270
+ async listRuns(request: TelemetryDashboardRunsRequestDto): Promise<TelemetryDashboardRunsDto> {
271
+ const spans = await this.resolveFilteredWorkflowRunSpans(request.filters);
272
+ const originByRunId = await this.loadRunOrigins(spans.map((span) => span.runId));
273
+ const costsByRunId = await this.loadCostsByRunId(
274
+ request.filters,
275
+ spans.map((span) => span.runId),
276
+ );
277
+ const items = spans
278
+ .slice()
279
+ .sort((left, right) => right.startTime!.localeCompare(left.startTime!))
280
+ .map((span) => ({
281
+ runId: span.runId,
282
+ workflowId: span.workflowId,
283
+ status: span.status ?? "running",
284
+ origin: originByRunId.get(span.runId) ?? "triggered",
285
+ startedAt: span.startTime ?? span.endTime ?? new Date(0).toISOString(),
286
+ finishedAt: span.endTime,
287
+ costs: costsByRunId.get(span.runId) ?? [],
288
+ }));
289
+ const offset = (request.page - 1) * request.pageSize;
290
+ return {
291
+ items: items.slice(offset, offset + request.pageSize),
292
+ totalCount: items.length,
293
+ page: request.page,
294
+ pageSize: request.pageSize,
295
+ };
296
+ }
297
+
298
+ async loadRunTrace(runId: string): Promise<TelemetryRunTraceViewDto> {
299
+ const trace = await this.runTraceContextRepository.load(runId);
300
+ const traceId = trace?.traceId ?? this.otelIdentityFactory.createTraceId(runId);
301
+ const [spans, artifacts, metricPoints] = await Promise.all([
302
+ this.telemetrySpanStore.listByTraceId(traceId),
303
+ this.telemetryArtifactStore.listByTraceId(traceId),
304
+ this.telemetryMetricPointStore.list({
305
+ runId,
306
+ traceId,
307
+ limit: TelemetryQueryService.queryScanLimit + 1,
308
+ }),
309
+ ]);
310
+ this.throwWhenQueryLimitExceeded(metricPoints.length);
311
+ return {
312
+ traceId,
313
+ runId,
314
+ spans,
315
+ artifacts,
316
+ metricPoints,
317
+ };
318
+ }
319
+
320
+ private durationMs(span: TelemetrySpanRecord): number {
321
+ if (!span.startTime || !span.endTime) {
322
+ return 0;
323
+ }
324
+ return Math.max(0, new Date(span.endTime).getTime() - new Date(span.startTime).getTime());
325
+ }
326
+
327
+ private async listWorkflowRunSpans(filters: TelemetryAggregateFilters): Promise<ReadonlyArray<TelemetrySpanRecord>> {
328
+ const spans = await this.telemetrySpanStore.list({
329
+ workflowId: filters.workflowId,
330
+ workflowIds: filters.workflowIds,
331
+ statuses: filters.statuses,
332
+ names: [TelemetryQueryService.workflowRunSpanName],
333
+ startTimeGte: filters.startTimeGte,
334
+ endTimeLte: filters.endTimeLte,
335
+ limit: TelemetryQueryService.queryScanLimit + 1,
336
+ });
337
+ this.throwWhenQueryLimitExceeded(spans.length);
338
+ return spans.filter((span) => Boolean(span.startTime));
339
+ }
340
+
341
+ private sumMetric(
342
+ points: ReadonlyArray<Readonly<{ metricName: string; value: number }>>,
343
+ metricName: string,
344
+ ): number {
345
+ return points.filter((point) => point.metricName === metricName).reduce((sum, point) => sum + point.value, 0);
346
+ }
347
+
348
+ private async resolveFilteredWorkflowRunSpans(
349
+ filters: TelemetryAggregateFilters,
350
+ ): Promise<ReadonlyArray<TelemetrySpanRecord>> {
351
+ const spans = await this.listWorkflowRunSpans(filters);
352
+ if (spans.length === 0) {
353
+ return [];
354
+ }
355
+ const eligibleRunIds = await this.resolveEligibleRunIds(filters, spans);
356
+ if (eligibleRunIds === null) {
357
+ return spans;
358
+ }
359
+ return spans.filter((span) => eligibleRunIds.has(span.runId));
360
+ }
361
+
362
+ private async resolveEligibleRunIds(
363
+ filters: TelemetryAggregateFilters,
364
+ spans: ReadonlyArray<TelemetrySpanRecord>,
365
+ ): Promise<ReadonlySet<string> | null> {
366
+ let eligibleRunIds: Set<string> | null = null;
367
+ if (filters.modelNames && filters.modelNames.length > 0) {
368
+ eligibleRunIds = await this.listModelMatchedRunIds(filters);
369
+ }
370
+ if (this.shouldApplyRunOriginFilter(filters.runOrigins)) {
371
+ const originMatchedRunIds = await this.listOriginMatchedRunIds(spans, filters.runOrigins!);
372
+ eligibleRunIds = eligibleRunIds ? this.intersectRunIds(eligibleRunIds, originMatchedRunIds) : originMatchedRunIds;
373
+ }
374
+ return eligibleRunIds;
375
+ }
376
+
377
+ private async listModelMatchedRunIds(filters: TelemetryAggregateFilters): Promise<Set<string>> {
378
+ const spans = await this.telemetrySpanStore.list({
379
+ workflowId: filters.workflowId,
380
+ workflowIds: filters.workflowIds,
381
+ modelNames: filters.modelNames,
382
+ startTimeGte: filters.startTimeGte,
383
+ endTimeLte: filters.endTimeLte,
384
+ limit: TelemetryQueryService.queryScanLimit + 1,
385
+ });
386
+ this.throwWhenQueryLimitExceeded(spans.length);
387
+ return new Set(spans.map((span) => span.runId));
388
+ }
389
+
390
+ private async listOriginMatchedRunIds(
391
+ spans: ReadonlyArray<TelemetrySpanRecord>,
392
+ runOrigins: ReadonlyArray<TelemetryDashboardRunOriginDto>,
393
+ ): Promise<Set<string>> {
394
+ const originByRunId = await this.loadRunOrigins(spans.map((span) => span.runId));
395
+ const matchedRunIds = new Set<string>();
396
+ for (const span of spans) {
397
+ const origin = originByRunId.get(span.runId) ?? "triggered";
398
+ if (runOrigins.includes(origin)) {
399
+ matchedRunIds.add(span.runId);
400
+ }
401
+ }
402
+ return matchedRunIds;
403
+ }
404
+
405
+ private async loadRunOrigins(runIds: ReadonlyArray<string>): Promise<Map<string, TelemetryDashboardRunOriginDto>> {
406
+ const uniqueRunIds = [...new Set(runIds)];
407
+ const states = await Promise.all(uniqueRunIds.map(async (runId) => await this.workflowRunRepository.load(runId)));
408
+ const originByRunId = new Map<string, TelemetryDashboardRunOriginDto>();
409
+ for (const state of states) {
410
+ if (!state) {
411
+ continue;
412
+ }
413
+ const mode = state.executionOptions?.mode;
414
+ originByRunId.set(state.runId, mode === "manual" || mode === "debug" ? "manual" : "triggered");
415
+ }
416
+ return originByRunId;
417
+ }
418
+
419
+ private intersectRunIds(left: ReadonlySet<string>, right: ReadonlySet<string>): Set<string> {
420
+ const intersection = new Set<string>();
421
+ for (const value of left) {
422
+ if (right.has(value)) {
423
+ intersection.add(value);
424
+ }
425
+ }
426
+ return intersection;
427
+ }
428
+
429
+ private shouldApplyRunOriginFilter(runOrigins: ReadonlyArray<TelemetryDashboardRunOriginDto> | undefined): boolean {
430
+ return Boolean(runOrigins && runOrigins.length > 0 && runOrigins.length < 2);
431
+ }
432
+
433
+ private async listAiMetricPoints(
434
+ filters: TelemetryAggregateFilters,
435
+ runIds: ReadonlyArray<string>,
436
+ ): Promise<ReadonlyArray<TelemetryMetricPointRecord>> {
437
+ const points = await this.telemetryMetricPointStore.list({
438
+ workflowId: filters.workflowId,
439
+ workflowIds: filters.workflowIds,
440
+ runIds: runIds.length > 0 ? runIds : undefined,
441
+ modelNames: filters.modelNames,
442
+ metricNames: [
443
+ GenAiTelemetryAttributeNames.usageInputTokens,
444
+ GenAiTelemetryAttributeNames.usageOutputTokens,
445
+ GenAiTelemetryAttributeNames.usageTotalTokens,
446
+ GenAiTelemetryAttributeNames.usageCacheReadInputTokens,
447
+ GenAiTelemetryAttributeNames.usageReasoningTokens,
448
+ ],
449
+ observedAtGte: filters.startTimeGte,
450
+ observedAtLte: filters.endTimeLte,
451
+ limit: TelemetryQueryService.queryScanLimit + 1,
452
+ });
453
+ this.throwWhenQueryLimitExceeded(points.length);
454
+ return points;
455
+ }
456
+
457
+ private async listCostMetricPoints(
458
+ filters: TelemetryAggregateFilters,
459
+ runIds: ReadonlyArray<string>,
460
+ ): Promise<ReadonlyArray<TelemetryMetricPointRecord>> {
461
+ const points = await this.telemetryMetricPointStore.list({
462
+ workflowId: filters.workflowId,
463
+ workflowIds: filters.workflowIds,
464
+ runIds: runIds.length > 0 ? runIds : undefined,
465
+ modelNames: filters.modelNames,
466
+ metricNames: [CostTrackingTelemetryMetricNames.estimatedCost],
467
+ observedAtGte: filters.startTimeGte,
468
+ observedAtLte: filters.endTimeLte,
469
+ limit: TelemetryQueryService.queryScanLimit + 1,
470
+ });
471
+ this.throwWhenQueryLimitExceeded(points.length);
472
+ return points;
473
+ }
474
+
475
+ private async loadCostsByRunId(
476
+ filters: TelemetryAggregateFilters,
477
+ runIds: ReadonlyArray<string>,
478
+ ): Promise<Map<string, ReadonlyArray<TelemetryDashboardBucketCostDto>>> {
479
+ if (runIds.length === 0) {
480
+ return new Map();
481
+ }
482
+ const points = await this.listCostMetricPoints(filters, runIds);
483
+ const totalsByRunId = new Map<string, Map<string, CostCurrencyAccumulator>>();
484
+ for (const point of points) {
485
+ if (!point.runId) {
486
+ continue;
487
+ }
488
+ const currency = this.readCostCurrency(point);
489
+ const currencyScale = this.readCostCurrencyScale(point);
490
+ if (!currency || currencyScale === undefined) {
491
+ continue;
492
+ }
493
+ const totals = this.getOrCreateRunCostTotals(totalsByRunId, point.runId);
494
+ this.accumulateCostTotal(totals, currency, currencyScale, point.value);
495
+ }
496
+ return new Map([...totalsByRunId.entries()].map(([runId, totals]) => [runId, this.toCostDtos(totals)]));
497
+ }
498
+
499
+ private buildCostCurrencyTotals(
500
+ points: ReadonlyArray<TelemetryMetricPointRecord>,
501
+ runCount: number,
502
+ costModelNamesBySpanId: ReadonlyMap<string, string>,
503
+ ): ReadonlyArray<TelemetryDashboardCostCurrencyTotalDto> {
504
+ const totals = new Map<string, CostCurrencyAggregateAccumulator>();
505
+ for (const point of points) {
506
+ const currency = this.readCostCurrency(point);
507
+ const currencyScale = this.readCostCurrencyScale(point);
508
+ if (!currency || currencyScale === undefined) {
509
+ continue;
510
+ }
511
+ const costKey = this.readCostKey(point, costModelNamesBySpanId);
512
+ const aggregate = this.getOrCreateCostAggregate(totals, currency, currencyScale);
513
+ aggregate.estimatedCostMinor += point.value;
514
+ if (costKey) {
515
+ aggregate.costKeyTotals.set(costKey, (aggregate.costKeyTotals.get(costKey) ?? 0) + point.value);
516
+ }
517
+ }
518
+ return [...totals.values()]
519
+ .map((aggregate) => ({
520
+ currency: aggregate.currency,
521
+ currencyScale: aggregate.currencyScale,
522
+ estimatedCostMinor: aggregate.estimatedCostMinor,
523
+ averageCostPerRunMinor: runCount === 0 ? 0 : Math.round(aggregate.estimatedCostMinor / runCount),
524
+ costKeys: [...aggregate.costKeyTotals.entries()]
525
+ .sort(([left], [right]) => left.localeCompare(right))
526
+ .map(([costKey, estimatedCostMinor]) => ({
527
+ costKey,
528
+ estimatedCostMinor,
529
+ })),
530
+ }))
531
+ .sort((left, right) => left.currency.localeCompare(right.currency));
532
+ }
533
+
534
+ private createBuckets(
535
+ filters: TelemetryAggregateFilters,
536
+ interval: TelemetryDashboardBucketIntervalDto,
537
+ ): Array<TelemetryTimeseriesBucket> {
538
+ if (!filters.startTimeGte || !filters.endTimeLte) {
539
+ throw new Error("Dashboard timeseries requires startTimeGte and endTimeLte.");
540
+ }
541
+ const buckets: Array<TelemetryTimeseriesBucket> = [];
542
+ const cursor = new Date(filters.startTimeGte);
543
+ const end = new Date(filters.endTimeLte);
544
+ if (Number.isNaN(cursor.getTime()) || Number.isNaN(end.getTime()) || cursor > end) {
545
+ throw new Error("Dashboard timeseries requires a valid date range.");
546
+ }
547
+ while (cursor <= end) {
548
+ const bucketStart = new Date(cursor);
549
+ const bucketEnd = this.advanceBucket(cursor, interval);
550
+ buckets.push({
551
+ bucketStartIso: bucketStart.toISOString(),
552
+ bucketEndIso: bucketEnd.toISOString(),
553
+ totalRuns: 0,
554
+ completedRuns: 0,
555
+ failedRuns: 0,
556
+ runningRuns: 0,
557
+ averageDurationMs: 0,
558
+ durationSamples: 0,
559
+ inputTokens: 0,
560
+ outputTokens: 0,
561
+ totalTokens: 0,
562
+ cachedInputTokens: 0,
563
+ reasoningTokens: 0,
564
+ costs: new Map(),
565
+ });
566
+ cursor.setTime(bucketEnd.getTime());
567
+ if (buckets.length > TelemetryQueryService.maxBucketCount) {
568
+ throw new Error(`Dashboard timeseries exceeded ${String(TelemetryQueryService.maxBucketCount)} buckets.`);
569
+ }
570
+ }
571
+ return buckets;
572
+ }
573
+
574
+ private advanceBucket(cursor: Date, interval: TelemetryDashboardBucketIntervalDto): Date {
575
+ const next = new Date(cursor);
576
+ if (interval === "minute_5") {
577
+ next.setUTCMinutes(next.getUTCMinutes() + 5, 0, 0);
578
+ return next;
579
+ }
580
+ if (interval === "minute_15") {
581
+ next.setUTCMinutes(next.getUTCMinutes() + 15, 0, 0);
582
+ return next;
583
+ }
584
+ if (interval === "hour") {
585
+ next.setUTCHours(next.getUTCHours() + 1, 0, 0, 0);
586
+ return next;
587
+ }
588
+ if (interval === "day") {
589
+ next.setUTCDate(next.getUTCDate() + 1);
590
+ return next;
591
+ }
592
+ next.setUTCDate(next.getUTCDate() + 7);
593
+ return next;
594
+ }
595
+
596
+ private findBucket(
597
+ buckets: ReadonlyArray<TelemetryTimeseriesBucket>,
598
+ observedAtIso: string | undefined,
599
+ ): TelemetryTimeseriesBucket | undefined {
600
+ if (!observedAtIso) {
601
+ return undefined;
602
+ }
603
+ const observedAt = new Date(observedAtIso).getTime();
604
+ if (Number.isNaN(observedAt)) {
605
+ return undefined;
606
+ }
607
+ return buckets.find((bucket) => {
608
+ const start = new Date(bucket.bucketStartIso).getTime();
609
+ const end = new Date(bucket.bucketEndIso).getTime();
610
+ return observedAt >= start && observedAt < end;
611
+ });
612
+ }
613
+
614
+ private toTimeseriesBucketDto(bucket: TelemetryTimeseriesBucket): TelemetryDashboardTimeseriesBucketDto {
615
+ return {
616
+ bucketStartIso: bucket.bucketStartIso,
617
+ bucketEndIso: bucket.bucketEndIso,
618
+ totalRuns: bucket.totalRuns,
619
+ completedRuns: bucket.completedRuns,
620
+ failedRuns: bucket.failedRuns,
621
+ runningRuns: bucket.runningRuns,
622
+ averageDurationMs:
623
+ bucket.durationSamples === 0 ? 0 : Math.round(bucket.averageDurationMs / bucket.durationSamples),
624
+ inputTokens: bucket.inputTokens,
625
+ outputTokens: bucket.outputTokens,
626
+ totalTokens: bucket.totalTokens,
627
+ cachedInputTokens: bucket.cachedInputTokens,
628
+ reasoningTokens: bucket.reasoningTokens,
629
+ costs: this.toCostDtos(bucket.costs),
630
+ };
631
+ }
632
+
633
+ private addCostPointToBucket(
634
+ bucket: TelemetryTimeseriesBucket,
635
+ point: TelemetryMetricPointRecord,
636
+ costModelNamesBySpanId: ReadonlyMap<string, string>,
637
+ ): void {
638
+ const currency = this.readCostCurrency(point);
639
+ const currencyScale = this.readCostCurrencyScale(point);
640
+ if (!currency || currencyScale === undefined) {
641
+ return;
642
+ }
643
+ this.accumulateCostTotal(
644
+ bucket.costs,
645
+ currency,
646
+ currencyScale,
647
+ point.value,
648
+ this.readCostComponent(point),
649
+ this.readCostKey(point, costModelNamesBySpanId),
650
+ );
651
+ }
652
+
653
+ private toCostDtos(
654
+ totals: ReadonlyMap<string, CostCurrencyAccumulator>,
655
+ ): ReadonlyArray<TelemetryDashboardBucketCostDto> {
656
+ return [...totals.values()]
657
+ .map((entry) => ({
658
+ currency: entry.currency,
659
+ currencyScale: entry.currencyScale,
660
+ estimatedCostMinor: entry.estimatedCostMinor,
661
+ component: entry.component,
662
+ costKey: entry.costKey,
663
+ }))
664
+ .sort((left, right) => left.currency.localeCompare(right.currency));
665
+ }
666
+
667
+ private accumulateCostTotal(
668
+ totals: Map<string, CostCurrencyAccumulator>,
669
+ currency: string,
670
+ currencyScale: number,
671
+ value: number,
672
+ component?: string,
673
+ costKey?: string,
674
+ ): void {
675
+ const key = this.createCostCurrencyKey(currency, currencyScale, component, costKey);
676
+ const existing = totals.get(key);
677
+ if (existing) {
678
+ existing.estimatedCostMinor += value;
679
+ return;
680
+ }
681
+ totals.set(key, {
682
+ currency,
683
+ currencyScale,
684
+ estimatedCostMinor: value,
685
+ component,
686
+ costKey,
687
+ });
688
+ }
689
+
690
+ private getOrCreateRunCostTotals(
691
+ totalsByRunId: Map<string, Map<string, CostCurrencyAccumulator>>,
692
+ runId: string,
693
+ ): Map<string, CostCurrencyAccumulator> {
694
+ const existing = totalsByRunId.get(runId);
695
+ if (existing) {
696
+ return existing;
697
+ }
698
+ const totals = new Map<string, CostCurrencyAccumulator>();
699
+ totalsByRunId.set(runId, totals);
700
+ return totals;
701
+ }
702
+
703
+ private getOrCreateCostAggregate(
704
+ totals: Map<string, CostCurrencyAggregateAccumulator>,
705
+ currency: string,
706
+ currencyScale: number,
707
+ ): CostCurrencyAggregateAccumulator {
708
+ const key = this.createCostCurrencyKey(currency, currencyScale);
709
+ const existing = totals.get(key);
710
+ if (existing) {
711
+ return existing;
712
+ }
713
+ const aggregate: CostCurrencyAggregateAccumulator = {
714
+ currency,
715
+ currencyScale,
716
+ estimatedCostMinor: 0,
717
+ costKeyTotals: new Map(),
718
+ };
719
+ totals.set(key, aggregate);
720
+ return aggregate;
721
+ }
722
+
723
+ private createCostCurrencyKey(currency: string, currencyScale: number, component?: string, costKey?: string): string {
724
+ return `${currency}::${String(currencyScale)}::${component ?? ""}::${costKey ?? ""}`;
725
+ }
726
+
727
+ private readCostComponent(point: TelemetryMetricPointRecord): string | undefined {
728
+ return this.readStringDimension(point, CostTrackingTelemetryAttributeNames.component);
729
+ }
730
+
731
+ private readCostKey(
732
+ point: TelemetryMetricPointRecord,
733
+ costModelNamesBySpanId: ReadonlyMap<string, string>,
734
+ ): string | undefined {
735
+ return (
736
+ point.modelName ??
737
+ (point.spanId ? costModelNamesBySpanId.get(point.spanId) : undefined) ??
738
+ this.readStringDimension(point, CostTrackingTelemetryAttributeNames.pricingKey) ??
739
+ this.readStringDimension(point, CostTrackingTelemetryAttributeNames.provider) ??
740
+ this.readStringDimension(point, CostTrackingTelemetryAttributeNames.component)
741
+ );
742
+ }
743
+
744
+ private async loadCostModelNamesBySpanId(
745
+ filters: TelemetryAggregateFilters,
746
+ runIds: ReadonlyArray<string>,
747
+ ): Promise<ReadonlyMap<string, string>> {
748
+ if (runIds.length === 0) {
749
+ return new Map();
750
+ }
751
+ const spans = await this.telemetrySpanStore.list({
752
+ workflowId: filters.workflowId,
753
+ workflowIds: filters.workflowIds,
754
+ runIds: [...new Set(runIds)],
755
+ startTimeGte: filters.startTimeGte,
756
+ endTimeLte: filters.endTimeLte,
757
+ limit: TelemetryQueryService.queryScanLimit + 1,
758
+ });
759
+ this.throwWhenQueryLimitExceeded(spans.length);
760
+ return new Map(
761
+ spans
762
+ .filter((span): span is TelemetrySpanRecord & { modelName: string } => typeof span.modelName === "string")
763
+ .map((span) => [span.spanId, span.modelName] as const),
764
+ );
765
+ }
766
+
767
+ private readCostCurrency(point: TelemetryMetricPointRecord): string | undefined {
768
+ return this.readStringDimension(point, CostTrackingTelemetryAttributeNames.currency) ?? point.unit;
769
+ }
770
+
771
+ private readCostCurrencyScale(point: TelemetryMetricPointRecord): number | undefined {
772
+ const value = point.dimensions?.[CostTrackingTelemetryAttributeNames.currencyScale];
773
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
774
+ }
775
+
776
+ private readStringDimension(point: TelemetryMetricPointRecord, key: string): string | undefined {
777
+ const value = point.dimensions?.[key];
778
+ return typeof value === "string" && value.length > 0 ? value : undefined;
779
+ }
780
+
781
+ private throwWhenQueryLimitExceeded(rowCount: number): void {
782
+ if (rowCount > TelemetryQueryService.queryScanLimit) {
783
+ throw new Error(`Telemetry dashboard query exceeded ${String(TelemetryQueryService.queryScanLimit)} rows.`);
784
+ }
785
+ }
786
+ }
787
+
788
+ interface TelemetryTimeseriesBucket {
789
+ readonly bucketStartIso: string;
790
+ readonly bucketEndIso: string;
791
+ totalRuns: number;
792
+ completedRuns: number;
793
+ failedRuns: number;
794
+ runningRuns: number;
795
+ averageDurationMs: number;
796
+ durationSamples: number;
797
+ inputTokens: number;
798
+ outputTokens: number;
799
+ totalTokens: number;
800
+ cachedInputTokens: number;
801
+ reasoningTokens: number;
802
+ costs: Map<string, CostCurrencyAccumulator>;
803
+ }
804
+
805
+ interface CostCurrencyAccumulator {
806
+ readonly currency: string;
807
+ readonly currencyScale: number;
808
+ estimatedCostMinor: number;
809
+ readonly component?: string;
810
+ readonly costKey?: string;
811
+ }
812
+
813
+ interface CostCurrencyAggregateAccumulator extends CostCurrencyAccumulator {
814
+ readonly costKeyTotals: Map<string, number>;
815
+ }