@oneuptime/common 11.2.2 → 11.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.
- package/Models/AnalyticsModels/Span.ts +285 -0
- package/Server/Infrastructure/Queue.ts +20 -0
- package/Server/Utils/Monitor/MonitorResource.ts +19 -1
- package/Server/Utils/Telemetry/LlmSpan.ts +251 -0
- package/Server/Utils/Telemetry.ts +91 -0
- package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +164 -0
- package/Tests/Types/Currency.test.ts +44 -0
- package/Tests/Types/Monitor/MonitorType.test.ts +214 -0
- package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +8 -3
- package/UI/Components/Navbar/NavBar.tsx +13 -2
- package/Utils/Dashboard/Components/DashboardTraceChartComponent.ts +6 -58
- package/build/dist/Models/AnalyticsModels/Span.js +231 -0
- package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +7 -1
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +17 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js +153 -0
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry.js +69 -0
- package/build/dist/Server/Utils/Telemetry.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +4 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js +6 -49
- package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js.map +1 -1
- package/package.json +1 -1
|
@@ -893,6 +893,192 @@ export default class Span extends AnalyticsBaseModel {
|
|
|
893
893
|
},
|
|
894
894
|
});
|
|
895
895
|
|
|
896
|
+
/*
|
|
897
|
+
* First-class LLM / GenAI / AI-agent columns. Denormalized at ingest from
|
|
898
|
+
* gen_ai.* (and OpenLLMetry/OpenInference) span attributes so LLM calls can
|
|
899
|
+
* be listed, filtered, and aggregated (tokens/cost/latency) cheaply without
|
|
900
|
+
* scanning the attributes map. See Common/Server/Utils/Telemetry/LlmSpan.ts.
|
|
901
|
+
*/
|
|
902
|
+
const llmColumnAccessControl: {
|
|
903
|
+
read: Array<Permission>;
|
|
904
|
+
create: Array<Permission>;
|
|
905
|
+
update: Array<Permission>;
|
|
906
|
+
} = {
|
|
907
|
+
read: [
|
|
908
|
+
Permission.ProjectOwner,
|
|
909
|
+
Permission.ProjectAdmin,
|
|
910
|
+
Permission.ProjectMember,
|
|
911
|
+
Permission.Viewer,
|
|
912
|
+
Permission.TelemetryAdmin,
|
|
913
|
+
Permission.TelemetryMember,
|
|
914
|
+
Permission.TelemetryViewer,
|
|
915
|
+
Permission.ReadTelemetryServiceTraces,
|
|
916
|
+
],
|
|
917
|
+
create: [
|
|
918
|
+
Permission.ProjectOwner,
|
|
919
|
+
Permission.ProjectAdmin,
|
|
920
|
+
Permission.ProjectMember,
|
|
921
|
+
Permission.TelemetryAdmin,
|
|
922
|
+
Permission.TelemetryMember,
|
|
923
|
+
Permission.CreateTelemetryServiceTraces,
|
|
924
|
+
],
|
|
925
|
+
update: [],
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const isLlmSpanColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
929
|
+
key: "isLlmSpan",
|
|
930
|
+
title: "Is LLM Span",
|
|
931
|
+
description:
|
|
932
|
+
"Whether this span is an LLM / GenAI / AI-agent operation, populated at ingest time for fast filtering of AI calls",
|
|
933
|
+
required: true,
|
|
934
|
+
defaultValue: false,
|
|
935
|
+
type: TableColumnType.Boolean,
|
|
936
|
+
skipIndex: {
|
|
937
|
+
name: "idx_is_llm_span",
|
|
938
|
+
type: SkipIndexType.Set,
|
|
939
|
+
params: [2],
|
|
940
|
+
granularity: 4,
|
|
941
|
+
},
|
|
942
|
+
accessControl: llmColumnAccessControl,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const llmSystemColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
946
|
+
key: "llmSystem",
|
|
947
|
+
isLowCardinality: true,
|
|
948
|
+
title: "LLM System",
|
|
949
|
+
description:
|
|
950
|
+
"GenAI provider / system, e.g. openai, anthropic, aws.bedrock (gen_ai.system)",
|
|
951
|
+
required: false,
|
|
952
|
+
type: TableColumnType.Text,
|
|
953
|
+
skipIndex: {
|
|
954
|
+
name: "idx_llm_system",
|
|
955
|
+
type: SkipIndexType.Set,
|
|
956
|
+
params: [100],
|
|
957
|
+
granularity: 4,
|
|
958
|
+
},
|
|
959
|
+
accessControl: llmColumnAccessControl,
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
const llmOperationColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
963
|
+
key: "llmOperation",
|
|
964
|
+
isLowCardinality: true,
|
|
965
|
+
title: "LLM Operation",
|
|
966
|
+
description:
|
|
967
|
+
"GenAI operation, e.g. chat, embeddings, execute_tool, invoke_agent (gen_ai.operation.name)",
|
|
968
|
+
required: false,
|
|
969
|
+
type: TableColumnType.Text,
|
|
970
|
+
skipIndex: {
|
|
971
|
+
name: "idx_llm_operation",
|
|
972
|
+
type: SkipIndexType.Set,
|
|
973
|
+
params: [100],
|
|
974
|
+
granularity: 4,
|
|
975
|
+
},
|
|
976
|
+
accessControl: llmColumnAccessControl,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const llmRequestModelColumn: AnalyticsTableColumn =
|
|
980
|
+
new AnalyticsTableColumn({
|
|
981
|
+
key: "llmRequestModel",
|
|
982
|
+
isLowCardinality: true,
|
|
983
|
+
title: "LLM Request Model",
|
|
984
|
+
description: "Model requested by the caller (gen_ai.request.model)",
|
|
985
|
+
required: false,
|
|
986
|
+
type: TableColumnType.Text,
|
|
987
|
+
skipIndex: {
|
|
988
|
+
name: "idx_llm_request_model",
|
|
989
|
+
type: SkipIndexType.Set,
|
|
990
|
+
params: [1000],
|
|
991
|
+
granularity: 4,
|
|
992
|
+
},
|
|
993
|
+
accessControl: llmColumnAccessControl,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const llmResponseModelColumn: AnalyticsTableColumn =
|
|
997
|
+
new AnalyticsTableColumn({
|
|
998
|
+
key: "llmResponseModel",
|
|
999
|
+
isLowCardinality: true,
|
|
1000
|
+
title: "LLM Response Model",
|
|
1001
|
+
description:
|
|
1002
|
+
"Model the provider actually served (gen_ai.response.model)",
|
|
1003
|
+
required: false,
|
|
1004
|
+
type: TableColumnType.Text,
|
|
1005
|
+
accessControl: llmColumnAccessControl,
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
const llmAgentNameColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
1009
|
+
key: "llmAgentName",
|
|
1010
|
+
codec: { codec: "ZSTD", level: 1 },
|
|
1011
|
+
title: "LLM Agent Name",
|
|
1012
|
+
description:
|
|
1013
|
+
"Name of the agent for agent-framework spans (gen_ai.agent.name)",
|
|
1014
|
+
required: false,
|
|
1015
|
+
type: TableColumnType.Text,
|
|
1016
|
+
accessControl: llmColumnAccessControl,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
const llmToolNameColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
1020
|
+
key: "llmToolName",
|
|
1021
|
+
codec: { codec: "ZSTD", level: 1 },
|
|
1022
|
+
title: "LLM Tool Name",
|
|
1023
|
+
description:
|
|
1024
|
+
"Name of the tool invoked for tool-call spans (gen_ai.tool.name)",
|
|
1025
|
+
required: false,
|
|
1026
|
+
type: TableColumnType.Text,
|
|
1027
|
+
accessControl: llmColumnAccessControl,
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const llmInputTokensColumn: AnalyticsTableColumn = new AnalyticsTableColumn(
|
|
1031
|
+
{
|
|
1032
|
+
key: "llmInputTokens",
|
|
1033
|
+
title: "LLM Input Tokens",
|
|
1034
|
+
description: "Input/prompt token count (gen_ai.usage.input_tokens)",
|
|
1035
|
+
required: true,
|
|
1036
|
+
defaultValue: 0,
|
|
1037
|
+
type: TableColumnType.Number,
|
|
1038
|
+
accessControl: llmColumnAccessControl,
|
|
1039
|
+
},
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
const llmOutputTokensColumn: AnalyticsTableColumn =
|
|
1043
|
+
new AnalyticsTableColumn({
|
|
1044
|
+
key: "llmOutputTokens",
|
|
1045
|
+
title: "LLM Output Tokens",
|
|
1046
|
+
description:
|
|
1047
|
+
"Output/completion token count (gen_ai.usage.output_tokens)",
|
|
1048
|
+
required: true,
|
|
1049
|
+
defaultValue: 0,
|
|
1050
|
+
type: TableColumnType.Number,
|
|
1051
|
+
accessControl: llmColumnAccessControl,
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const llmTotalTokensColumn: AnalyticsTableColumn = new AnalyticsTableColumn(
|
|
1055
|
+
{
|
|
1056
|
+
key: "llmTotalTokens",
|
|
1057
|
+
title: "LLM Total Tokens",
|
|
1058
|
+
description:
|
|
1059
|
+
"Total token count (gen_ai.usage.total_tokens, or input + output)",
|
|
1060
|
+
required: true,
|
|
1061
|
+
defaultValue: 0,
|
|
1062
|
+
type: TableColumnType.Number,
|
|
1063
|
+
accessControl: llmColumnAccessControl,
|
|
1064
|
+
},
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const llmCostColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
1068
|
+
key: "llmCost",
|
|
1069
|
+
title: "LLM Cost (USD)",
|
|
1070
|
+
description:
|
|
1071
|
+
"Cost of this LLM call in USD. Only populated when the instrumentation reports it (gen_ai.usage.cost); OneUptime does not compute pricing.",
|
|
1072
|
+
required: true,
|
|
1073
|
+
defaultValue: 0,
|
|
1074
|
+
/*
|
|
1075
|
+
* Decimal (ClickHouse Double) — cost is fractional; an Int column would
|
|
1076
|
+
* reject the fractional JSONEachRow value and poison the whole batch.
|
|
1077
|
+
*/
|
|
1078
|
+
type: TableColumnType.Decimal,
|
|
1079
|
+
accessControl: llmColumnAccessControl,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
896
1082
|
const retentionDateColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
897
1083
|
key: "retentionDate",
|
|
898
1084
|
codec: [{ codec: "DoubleDelta" }, { codec: "ZSTD", level: 1 }],
|
|
@@ -974,6 +1160,17 @@ export default class Span extends AnalyticsBaseModel {
|
|
|
974
1160
|
kindColumn,
|
|
975
1161
|
hasExceptionColumn,
|
|
976
1162
|
isRootSpanColumn,
|
|
1163
|
+
isLlmSpanColumn,
|
|
1164
|
+
llmSystemColumn,
|
|
1165
|
+
llmOperationColumn,
|
|
1166
|
+
llmRequestModelColumn,
|
|
1167
|
+
llmResponseModelColumn,
|
|
1168
|
+
llmAgentNameColumn,
|
|
1169
|
+
llmToolNameColumn,
|
|
1170
|
+
llmInputTokensColumn,
|
|
1171
|
+
llmOutputTokensColumn,
|
|
1172
|
+
llmTotalTokensColumn,
|
|
1173
|
+
llmCostColumn,
|
|
977
1174
|
retentionDateColumn,
|
|
978
1175
|
],
|
|
979
1176
|
projections: [
|
|
@@ -1199,4 +1396,92 @@ export default class Span extends AnalyticsBaseModel {
|
|
|
1199
1396
|
public set retentionDate(v: Date | undefined) {
|
|
1200
1397
|
this.setColumnValue("retentionDate", v);
|
|
1201
1398
|
}
|
|
1399
|
+
|
|
1400
|
+
public get isLlmSpan(): boolean | undefined {
|
|
1401
|
+
return this.getColumnValue("isLlmSpan") as boolean | undefined;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
public set isLlmSpan(v: boolean | undefined) {
|
|
1405
|
+
this.setColumnValue("isLlmSpan", v);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
public get llmSystem(): string | undefined {
|
|
1409
|
+
return this.getColumnValue("llmSystem") as string | undefined;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
public set llmSystem(v: string | undefined) {
|
|
1413
|
+
this.setColumnValue("llmSystem", v);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
public get llmOperation(): string | undefined {
|
|
1417
|
+
return this.getColumnValue("llmOperation") as string | undefined;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
public set llmOperation(v: string | undefined) {
|
|
1421
|
+
this.setColumnValue("llmOperation", v);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
public get llmRequestModel(): string | undefined {
|
|
1425
|
+
return this.getColumnValue("llmRequestModel") as string | undefined;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
public set llmRequestModel(v: string | undefined) {
|
|
1429
|
+
this.setColumnValue("llmRequestModel", v);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
public get llmResponseModel(): string | undefined {
|
|
1433
|
+
return this.getColumnValue("llmResponseModel") as string | undefined;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
public set llmResponseModel(v: string | undefined) {
|
|
1437
|
+
this.setColumnValue("llmResponseModel", v);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
public get llmAgentName(): string | undefined {
|
|
1441
|
+
return this.getColumnValue("llmAgentName") as string | undefined;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
public set llmAgentName(v: string | undefined) {
|
|
1445
|
+
this.setColumnValue("llmAgentName", v);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
public get llmToolName(): string | undefined {
|
|
1449
|
+
return this.getColumnValue("llmToolName") as string | undefined;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
public set llmToolName(v: string | undefined) {
|
|
1453
|
+
this.setColumnValue("llmToolName", v);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
public get llmInputTokens(): number | undefined {
|
|
1457
|
+
return this.getColumnValue("llmInputTokens") as number | undefined;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
public set llmInputTokens(v: number | undefined) {
|
|
1461
|
+
this.setColumnValue("llmInputTokens", v);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
public get llmOutputTokens(): number | undefined {
|
|
1465
|
+
return this.getColumnValue("llmOutputTokens") as number | undefined;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
public set llmOutputTokens(v: number | undefined) {
|
|
1469
|
+
this.setColumnValue("llmOutputTokens", v);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
public get llmTotalTokens(): number | undefined {
|
|
1473
|
+
return this.getColumnValue("llmTotalTokens") as number | undefined;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
public set llmTotalTokens(v: number | undefined) {
|
|
1477
|
+
this.setColumnValue("llmTotalTokens", v);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
public get llmCost(): number | undefined {
|
|
1481
|
+
return this.getColumnValue("llmCost") as number | undefined;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
public set llmCost(v: number | undefined) {
|
|
1485
|
+
this.setColumnValue("llmCost", v);
|
|
1486
|
+
}
|
|
1202
1487
|
}
|
|
@@ -219,6 +219,19 @@ export default class Queue {
|
|
|
219
219
|
* timestamp.
|
|
220
220
|
*/
|
|
221
221
|
skipExistenceCheck?: boolean | undefined;
|
|
222
|
+
/**
|
|
223
|
+
* Coalesce same-key jobs so they are never processed in parallel.
|
|
224
|
+
* When set, BullMQ keeps at most one active + one waiting job per
|
|
225
|
+
* `deduplication.id`: additional adds while one is active collapse into
|
|
226
|
+
* the single waiting slot, keeping only the latest job data
|
|
227
|
+
* (keepLastIfActive). Used by the incoming-request ingest path to stop
|
|
228
|
+
* an external sender hammering one monitor's URL from fanning out into
|
|
229
|
+
* many concurrent same-monitor jobs that contend on the per-monitor
|
|
230
|
+
* lock. Independent of `jobId`, which stays unique.
|
|
231
|
+
*/
|
|
232
|
+
deduplication?:
|
|
233
|
+
| { id: string; keepLastIfActive?: boolean | undefined }
|
|
234
|
+
| undefined;
|
|
222
235
|
},
|
|
223
236
|
): Promise<Job> {
|
|
224
237
|
const sanitizedJobId: string = this.sanitizeJobId(jobId.toString());
|
|
@@ -242,6 +255,13 @@ export default class Queue {
|
|
|
242
255
|
};
|
|
243
256
|
}
|
|
244
257
|
|
|
258
|
+
if (options?.deduplication?.id) {
|
|
259
|
+
optionsObject.deduplication = {
|
|
260
|
+
id: this.sanitizeJobId(options.deduplication.id),
|
|
261
|
+
keepLastIfActive: options.deduplication.keepLastIfActive ?? false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
245
265
|
const queue: BullQueue = this.getQueue(queueName);
|
|
246
266
|
|
|
247
267
|
if (options && options.scheduleAt) {
|
|
@@ -188,6 +188,21 @@ export default class MonitorResourceUtil {
|
|
|
188
188
|
* below guarantees the lock is released on every exit path (return or
|
|
189
189
|
* throw); acquireTimeout/retryInterval cap the acquire spin so a contended
|
|
190
190
|
* lock fails fast instead of polling Redis for 10s.
|
|
191
|
+
*
|
|
192
|
+
* On acquire timeout we DO NOT continue unlocked. Falling through without
|
|
193
|
+
* the lock silently abandons the per-monitor serialization this lock exists
|
|
194
|
+
* to provide — concurrent results for the same monitor would then race
|
|
195
|
+
* incident/alert dedup and status-timeline writes (duplicate
|
|
196
|
+
* incidents/alerts, status flaps) exactly under the high contention the
|
|
197
|
+
* lock is meant to handle. Instead we surface the contention so the work is
|
|
198
|
+
* retried once the lock frees: the Telemetry queue re-runs each ingest job
|
|
199
|
+
* up to 3x with exponential backoff, and the per-monitor crons catch-and-
|
|
200
|
+
* skip to re-evaluate on their next tick. The dominant source of
|
|
201
|
+
* same-monitor contention (an external sender hammering one Incoming
|
|
202
|
+
* Request URL) is collapsed upstream by BullMQ job coalescing at enqueue
|
|
203
|
+
* time (see TelemetryQueueService.addIncomingRequestIngestJob), so this
|
|
204
|
+
* throw is a correctness backstop for the rare residual collision (e.g.
|
|
205
|
+
* cron vs ingest), not the steady-state path.
|
|
191
206
|
*/
|
|
192
207
|
let mutex: SemaphoreMutex | null = null;
|
|
193
208
|
|
|
@@ -200,7 +215,10 @@ export default class MonitorResourceUtil {
|
|
|
200
215
|
acquireAttemptsLimit: 20,
|
|
201
216
|
});
|
|
202
217
|
} catch (err) {
|
|
203
|
-
logger.
|
|
218
|
+
logger.debug(
|
|
219
|
+
`${dataToProcess.monitorId.toString()} - Could not acquire per-monitor processing lock within the acquire window; another worker is processing this monitor. Deferring to retry to preserve serialization.`,
|
|
220
|
+
);
|
|
221
|
+
throw err;
|
|
204
222
|
}
|
|
205
223
|
|
|
206
224
|
const releaseMutex: () => Promise<void> = async (): Promise<void> => {
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
2
|
+
import { AttributeType } from "./Telemetry";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* First-class detection of LLM / GenAI / AI-agent spans.
|
|
6
|
+
*
|
|
7
|
+
* OneUptime ingests OpenTelemetry spans generically. To make LLM and agent
|
|
8
|
+
* telemetry a first-class signal (filterable lists, token/cost/latency
|
|
9
|
+
* rollups) we denormalize a small set of values out of the span attributes at
|
|
10
|
+
* ingest time. We recognize the OpenTelemetry GenAI semantic conventions
|
|
11
|
+
* (gen_ai.*) as primary, with cheap fallbacks for the two dominant
|
|
12
|
+
* instrumentation libraries:
|
|
13
|
+
* - OpenLLMetry / Traceloop (gen_ai.* + traceloop.*)
|
|
14
|
+
* - OpenInference / Arize (llm.* + openinference.span.kind)
|
|
15
|
+
*
|
|
16
|
+
* Prompt/completion CONTENT is intentionally NOT denormalized here — it stays
|
|
17
|
+
* in the span's attributes/events map (already captured + scrubbed) and is
|
|
18
|
+
* rendered by the LLM span panel in the dashboard.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface LlmSpanFields {
|
|
22
|
+
// True when this span looks like an LLM / GenAI / agent operation.
|
|
23
|
+
isLlmSpan: boolean;
|
|
24
|
+
// Provider / system, e.g. "openai", "anthropic", "aws.bedrock".
|
|
25
|
+
llmSystem: string;
|
|
26
|
+
// Operation, e.g. "chat", "embeddings", "execute_tool", "invoke_agent".
|
|
27
|
+
llmOperation: string;
|
|
28
|
+
// Model requested by the caller.
|
|
29
|
+
llmRequestModel: string;
|
|
30
|
+
// Model the provider actually served (often the resolved/pinned model).
|
|
31
|
+
llmResponseModel: string;
|
|
32
|
+
// Token usage. 0 when the instrumentation did not report it.
|
|
33
|
+
llmInputTokens: number;
|
|
34
|
+
llmOutputTokens: number;
|
|
35
|
+
llmTotalTokens: number;
|
|
36
|
+
// Cost in USD. Only populated when the SDK reports it (no built-in pricing).
|
|
37
|
+
llmCost: number;
|
|
38
|
+
// Agent / tool names for agent-framework spans.
|
|
39
|
+
llmAgentName: string;
|
|
40
|
+
llmToolName: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type SpanAttributes = Dictionary<AttributeType | Array<AttributeType>>;
|
|
44
|
+
|
|
45
|
+
export default class LlmSpanUtil {
|
|
46
|
+
/**
|
|
47
|
+
* Return the empty/default LLM field set (non-LLM span).
|
|
48
|
+
*/
|
|
49
|
+
public static empty(): LlmSpanFields {
|
|
50
|
+
return {
|
|
51
|
+
isLlmSpan: false,
|
|
52
|
+
llmSystem: "",
|
|
53
|
+
llmOperation: "",
|
|
54
|
+
llmRequestModel: "",
|
|
55
|
+
llmResponseModel: "",
|
|
56
|
+
llmInputTokens: 0,
|
|
57
|
+
llmOutputTokens: 0,
|
|
58
|
+
llmTotalTokens: 0,
|
|
59
|
+
llmCost: 0,
|
|
60
|
+
llmAgentName: "",
|
|
61
|
+
llmToolName: "",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract first-class LLM fields from a flattened span attribute dictionary.
|
|
67
|
+
* Pure + side-effect free so it can be unit tested in isolation.
|
|
68
|
+
*/
|
|
69
|
+
public static extract(attributes: SpanAttributes): LlmSpanFields {
|
|
70
|
+
const fields: LlmSpanFields = this.empty();
|
|
71
|
+
|
|
72
|
+
if (!attributes || typeof attributes !== "object") {
|
|
73
|
+
return fields;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const keys: Array<string> = Object.keys(attributes);
|
|
77
|
+
|
|
78
|
+
if (keys.length === 0) {
|
|
79
|
+
return fields;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fields.llmSystem = this.getString(attributes, [
|
|
83
|
+
"gen_ai.system",
|
|
84
|
+
"gen_ai.provider.name",
|
|
85
|
+
"llm.system",
|
|
86
|
+
"llm.provider",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
fields.llmOperation = this.getString(attributes, [
|
|
90
|
+
"gen_ai.operation.name",
|
|
91
|
+
"llm.request.type",
|
|
92
|
+
"openinference.span.kind",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
fields.llmRequestModel = this.getString(attributes, [
|
|
96
|
+
"gen_ai.request.model",
|
|
97
|
+
"llm.model_name",
|
|
98
|
+
"llm.request.model",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
fields.llmResponseModel = this.getString(attributes, [
|
|
102
|
+
"gen_ai.response.model",
|
|
103
|
+
"llm.response.model",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
// Fall back to the response model when no request model was reported.
|
|
107
|
+
if (!fields.llmRequestModel && fields.llmResponseModel) {
|
|
108
|
+
fields.llmRequestModel = fields.llmResponseModel;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/*
|
|
112
|
+
* Token columns are ClickHouse Int32 — truncate any fractional value a
|
|
113
|
+
* malformed SDK might report, otherwise the JSONEachRow insert would
|
|
114
|
+
* reject the row and fail the whole span batch.
|
|
115
|
+
*/
|
|
116
|
+
fields.llmInputTokens = Math.trunc(
|
|
117
|
+
this.getNumber(attributes, [
|
|
118
|
+
"gen_ai.usage.input_tokens",
|
|
119
|
+
"gen_ai.usage.prompt_tokens",
|
|
120
|
+
"llm.token_count.prompt",
|
|
121
|
+
"llm.usage.prompt_tokens",
|
|
122
|
+
]),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
fields.llmOutputTokens = Math.trunc(
|
|
126
|
+
this.getNumber(attributes, [
|
|
127
|
+
"gen_ai.usage.output_tokens",
|
|
128
|
+
"gen_ai.usage.completion_tokens",
|
|
129
|
+
"llm.token_count.completion",
|
|
130
|
+
"llm.usage.completion_tokens",
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
fields.llmTotalTokens = Math.trunc(
|
|
135
|
+
this.getNumber(attributes, [
|
|
136
|
+
"gen_ai.usage.total_tokens",
|
|
137
|
+
"llm.token_count.total",
|
|
138
|
+
"llm.usage.total_tokens",
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Derive total when only the parts were reported.
|
|
143
|
+
if (
|
|
144
|
+
fields.llmTotalTokens === 0 &&
|
|
145
|
+
(fields.llmInputTokens > 0 || fields.llmOutputTokens > 0)
|
|
146
|
+
) {
|
|
147
|
+
fields.llmTotalTokens = fields.llmInputTokens + fields.llmOutputTokens;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fields.llmCost = this.getNumber(attributes, [
|
|
151
|
+
"gen_ai.usage.cost",
|
|
152
|
+
"gen_ai.usage.cost_usd",
|
|
153
|
+
"gen_ai.usage.total_cost",
|
|
154
|
+
"llm.usage.total_cost",
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
fields.llmAgentName = this.getString(attributes, [
|
|
158
|
+
"gen_ai.agent.name",
|
|
159
|
+
"agent.name",
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
fields.llmToolName = this.getString(attributes, [
|
|
163
|
+
"gen_ai.tool.name",
|
|
164
|
+
"tool.name",
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
fields.isLlmSpan = this.detectIsLlmSpan(keys, fields);
|
|
168
|
+
|
|
169
|
+
return fields;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static detectIsLlmSpan(
|
|
173
|
+
keys: Array<string>,
|
|
174
|
+
fields: LlmSpanFields,
|
|
175
|
+
): boolean {
|
|
176
|
+
if (
|
|
177
|
+
fields.llmSystem ||
|
|
178
|
+
fields.llmOperation ||
|
|
179
|
+
fields.llmRequestModel ||
|
|
180
|
+
fields.llmResponseModel ||
|
|
181
|
+
fields.llmAgentName ||
|
|
182
|
+
fields.llmToolName ||
|
|
183
|
+
fields.llmTotalTokens > 0
|
|
184
|
+
) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Last-resort: any GenAI/LLM-namespaced attribute at all.
|
|
189
|
+
return keys.some((key: string) => {
|
|
190
|
+
return (
|
|
191
|
+
key.startsWith("gen_ai.") ||
|
|
192
|
+
key.startsWith("llm.") ||
|
|
193
|
+
key.startsWith("traceloop.")
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private static getString(
|
|
199
|
+
attributes: SpanAttributes,
|
|
200
|
+
candidateKeys: Array<string>,
|
|
201
|
+
): string {
|
|
202
|
+
for (const key of candidateKeys) {
|
|
203
|
+
const value: AttributeType | Array<AttributeType> | undefined =
|
|
204
|
+
attributes[key];
|
|
205
|
+
|
|
206
|
+
if (value === undefined || value === null) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const stringValue: string = String(value).trim();
|
|
215
|
+
|
|
216
|
+
if (stringValue) {
|
|
217
|
+
return stringValue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private static getNumber(
|
|
225
|
+
attributes: SpanAttributes,
|
|
226
|
+
candidateKeys: Array<string>,
|
|
227
|
+
): number {
|
|
228
|
+
for (const key of candidateKeys) {
|
|
229
|
+
const value: AttributeType | Array<AttributeType> | undefined =
|
|
230
|
+
attributes[key];
|
|
231
|
+
|
|
232
|
+
if (value === undefined || value === null || Array.isArray(value)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (typeof value === "number" && isFinite(value)) {
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
241
|
+
const parsed: number = Number(value);
|
|
242
|
+
|
|
243
|
+
if (isFinite(parsed)) {
|
|
244
|
+
return parsed;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
}
|