@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.
Files changed (26) hide show
  1. package/Models/AnalyticsModels/Span.ts +285 -0
  2. package/Server/Infrastructure/Queue.ts +20 -0
  3. package/Server/Utils/Monitor/MonitorResource.ts +19 -1
  4. package/Server/Utils/Telemetry/LlmSpan.ts +251 -0
  5. package/Server/Utils/Telemetry.ts +91 -0
  6. package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +164 -0
  7. package/Tests/Types/Currency.test.ts +44 -0
  8. package/Tests/Types/Monitor/MonitorType.test.ts +214 -0
  9. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +8 -3
  10. package/UI/Components/Navbar/NavBar.tsx +13 -2
  11. package/Utils/Dashboard/Components/DashboardTraceChartComponent.ts +6 -58
  12. package/build/dist/Models/AnalyticsModels/Span.js +231 -0
  13. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  14. package/build/dist/Server/Infrastructure/Queue.js +7 -1
  15. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  16. package/build/dist/Server/Utils/Monitor/MonitorResource.js +17 -1
  17. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  18. package/build/dist/Server/Utils/Telemetry/LlmSpan.js +153 -0
  19. package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -0
  20. package/build/dist/Server/Utils/Telemetry.js +69 -0
  21. package/build/dist/Server/Utils/Telemetry.js.map +1 -1
  22. package/build/dist/UI/Components/Navbar/NavBar.js +4 -1
  23. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  24. package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js +6 -49
  25. package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js.map +1 -1
  26. 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.error(err);
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
+ }