@oneuptime/common 11.2.3 → 11.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/AnalyticsModels/Span.ts +285 -0
- package/Server/Infrastructure/Queue.ts +20 -0
- package/Server/Services/ProjectService.ts +429 -300
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +26 -4
- package/Server/Utils/Monitor/MonitorResource.ts +19 -1
- package/Server/Utils/Telemetry/LlmSpan.ts +251 -0
- package/Server/Utils/Telemetry.ts +154 -7
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +25 -12
- package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +164 -0
- package/Tests/Server/Utils/Telemetry.test.ts +251 -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/Services/ProjectService.js +371 -277
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -4
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.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 +128 -7
- 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
|
@@ -1327,10 +1327,26 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
1327
1327
|
);
|
|
1328
1328
|
}
|
|
1329
1329
|
|
|
1330
|
+
/*
|
|
1331
|
+
* ON CLUSTER is appended as RAW SQL right after the table reference. The
|
|
1332
|
+
* analytics schema is ALWAYS a sharded + replicated cluster (see
|
|
1333
|
+
* ClusterConfig: a single node is just a "cluster of one"). A bare
|
|
1334
|
+
* `ALTER … ADD COLUMN` reaches only the shard the client is connected to —
|
|
1335
|
+
* Keeper replicates within a shard but never across shards — so the column
|
|
1336
|
+
* lands on one shard and a later scatter-gather read through the Distributed
|
|
1337
|
+
* wrapper hits a shard that lacks it and fails with
|
|
1338
|
+
* "Missing columns: '<col>'" (Code 47 UNKNOWN_IDENTIFIER). ON CLUSTER also
|
|
1339
|
+
* makes this ADD COLUMN wait for cluster-wide completion, so the separate
|
|
1340
|
+
* `ADD INDEX` that addColumnInDatabase issues next never races a column that
|
|
1341
|
+
* has not yet propagated to the node the index DDL lands on.
|
|
1342
|
+
*/
|
|
1330
1343
|
const statement: Statement = SQL`
|
|
1331
1344
|
ALTER TABLE ${this.database.getDatasourceOptions().database!}.${getStorageTableName(
|
|
1332
1345
|
this.model.tableName,
|
|
1333
|
-
)}
|
|
1346
|
+
)}`
|
|
1347
|
+
.append(onClusterClause())
|
|
1348
|
+
.append(" ADD COLUMN IF NOT EXISTS ")
|
|
1349
|
+
.append(columnDef);
|
|
1334
1350
|
|
|
1335
1351
|
logger.debug(`${this.model.tableName} Add Column Statement`);
|
|
1336
1352
|
logger.debug(statement);
|
|
@@ -1359,8 +1375,14 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
1359
1375
|
|
|
1360
1376
|
const databaseName: string = this.database.getDatasourceOptions().database!;
|
|
1361
1377
|
const statement: Statement = new Statement();
|
|
1378
|
+
/*
|
|
1379
|
+
* ON CLUSTER (raw SQL) so the skip index is added on every shard, matching
|
|
1380
|
+
* toAddColumnStatement. A bare ADD INDEX only reaches the connected shard,
|
|
1381
|
+
* and if the column it references has not propagated to that node yet it
|
|
1382
|
+
* fails with "Missing columns: '<col>'" (Code 47).
|
|
1383
|
+
*/
|
|
1362
1384
|
statement.append(
|
|
1363
|
-
`ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)} ADD INDEX IF NOT EXISTS ${idx.name} ${columnExpr} TYPE ${idx.type}${paramsStr} GRANULARITY ${idx.granularity}`,
|
|
1385
|
+
`ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)}${onClusterClause()} ADD INDEX IF NOT EXISTS ${idx.name} ${columnExpr} TYPE ${idx.type}${paramsStr} GRANULARITY ${idx.granularity}`,
|
|
1364
1386
|
);
|
|
1365
1387
|
|
|
1366
1388
|
logger.debug(`${this.model.tableName} Add Skip Index Statement`);
|
|
@@ -1371,7 +1393,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
1371
1393
|
|
|
1372
1394
|
public toDropSkipIndexStatement(indexName: string): string {
|
|
1373
1395
|
const databaseName: string = this.database.getDatasourceOptions().database!;
|
|
1374
|
-
const statement: string = `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)} DROP INDEX IF EXISTS ${indexName}`;
|
|
1396
|
+
const statement: string = `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)}${onClusterClause()} DROP INDEX IF EXISTS ${indexName}`;
|
|
1375
1397
|
|
|
1376
1398
|
logger.debug(`${this.model.tableName} Drop Skip Index Statement`);
|
|
1377
1399
|
logger.debug(statement);
|
|
@@ -1383,7 +1405,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
1383
1405
|
const statement: string = `ALTER TABLE ${this.database.getDatasourceOptions()
|
|
1384
1406
|
.database!}.${getStorageTableName(
|
|
1385
1407
|
this.model.tableName,
|
|
1386
|
-
)} DROP COLUMN IF EXISTS ${columnName}`;
|
|
1408
|
+
)}${onClusterClause()} DROP COLUMN IF EXISTS ${columnName}`;
|
|
1387
1409
|
|
|
1388
1410
|
logger.debug(`${this.model.tableName} Drop Column Statement`);
|
|
1389
1411
|
logger.debug(statement);
|
|
@@ -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
|
+
}
|
|
@@ -524,15 +524,162 @@ export default class Telemetry {
|
|
|
524
524
|
}): void {
|
|
525
525
|
const { span, exception } = data;
|
|
526
526
|
|
|
527
|
-
|
|
528
|
-
|
|
527
|
+
/*
|
|
528
|
+
* This runs on the universal catch path of every @CaptureSpan-decorated
|
|
529
|
+
* function, so it must NEVER throw — a throw here would mask the original
|
|
530
|
+
* error and skip marking the span. The whole body is wrapped defensively
|
|
531
|
+
* and the span is always marked-as-error and ended (in the finally).
|
|
532
|
+
*/
|
|
533
|
+
try {
|
|
534
|
+
const exceptionAttributes: Attributes =
|
|
535
|
+
this.getExceptionAttributes(exception);
|
|
536
|
+
|
|
537
|
+
// log the exception as well
|
|
538
|
+
logger.error(exception);
|
|
529
539
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
540
|
+
/*
|
|
541
|
+
* Span *events* (from recordException) are not reliably surfaced when the
|
|
542
|
+
* span is read back, and setStatus on its own only records the error CODE,
|
|
543
|
+
* not the message. So we also attach the exception details as queryable
|
|
544
|
+
* span attributes — including DB driver fields like the failing constraint
|
|
545
|
+
* and table — so the actual cause is visible in the trace UI instead of an
|
|
546
|
+
* empty "Error" status.
|
|
547
|
+
*/
|
|
548
|
+
span.setAttributes(exceptionAttributes);
|
|
549
|
+
span.recordException(exception as SpanException);
|
|
550
|
+
span.setStatus({
|
|
551
|
+
code: SpanStatusCode.ERROR,
|
|
552
|
+
message:
|
|
553
|
+
(exceptionAttributes["exception.message"] as string | undefined) ||
|
|
554
|
+
"Error",
|
|
555
|
+
});
|
|
556
|
+
} catch {
|
|
557
|
+
// Enrichment failed on some exotic thrown value — still flag the span.
|
|
558
|
+
try {
|
|
559
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
560
|
+
} catch {
|
|
561
|
+
// span may already be ended; nothing more we can do.
|
|
562
|
+
}
|
|
563
|
+
} finally {
|
|
564
|
+
this.endSpan(span);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/*
|
|
569
|
+
* Pulls every useful field off an unknown thrown value into OpenTelemetry
|
|
570
|
+
* span attributes. Error message/stack are non-enumerable, so they are read
|
|
571
|
+
* explicitly. For database failures (TypeORM QueryFailedError / pg errors),
|
|
572
|
+
* the Postgres fields (SQLSTATE code, detail, constraint, table, column) live
|
|
573
|
+
* either on the error itself or on `driverError`; these are what tell us which
|
|
574
|
+
* constraint failed during e.g. a cascade delete.
|
|
575
|
+
*
|
|
576
|
+
* The thrown value is `unknown`, so anything (a Proxy, an object with a
|
|
577
|
+
* throwing getter or a throwing toString) could be passed — every field read
|
|
578
|
+
* and string coercion is guarded so this can never throw on the error path.
|
|
579
|
+
*/
|
|
580
|
+
private static getExceptionAttributes(exception: unknown): Attributes {
|
|
581
|
+
const attributes: Attributes = {};
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
if (exception === null || exception === undefined) {
|
|
585
|
+
attributes["exception.message"] =
|
|
586
|
+
"Unknown error: null or undefined was thrown";
|
|
587
|
+
return attributes;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (exception instanceof Error) {
|
|
591
|
+
attributes["exception.type"] =
|
|
592
|
+
exception.name || exception.constructor?.name || "Error";
|
|
593
|
+
attributes["exception.message"] = this.truncate(
|
|
594
|
+
exception.message || "",
|
|
595
|
+
4000,
|
|
596
|
+
);
|
|
597
|
+
if (exception.stack) {
|
|
598
|
+
attributes["exception.stacktrace"] = this.truncate(
|
|
599
|
+
exception.stack,
|
|
600
|
+
8000,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
} else if (typeof exception === "string") {
|
|
604
|
+
attributes["exception.message"] = this.truncate(exception, 4000);
|
|
605
|
+
} else {
|
|
606
|
+
attributes["exception.message"] = this.truncate(
|
|
607
|
+
this.safeStringify(exception),
|
|
608
|
+
4000,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
534
611
|
|
|
535
|
-
|
|
612
|
+
type PotentialDatabaseError = {
|
|
613
|
+
code?: unknown;
|
|
614
|
+
detail?: unknown;
|
|
615
|
+
constraint?: unknown;
|
|
616
|
+
table?: unknown;
|
|
617
|
+
column?: unknown;
|
|
618
|
+
schema?: unknown;
|
|
619
|
+
query?: unknown;
|
|
620
|
+
driverError?: PotentialDatabaseError;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const error: PotentialDatabaseError = exception as PotentialDatabaseError;
|
|
624
|
+
const databaseError: PotentialDatabaseError = error.driverError || error;
|
|
625
|
+
|
|
626
|
+
const setStringAttribute: (key: string, value: unknown) => void = (
|
|
627
|
+
key: string,
|
|
628
|
+
value: unknown,
|
|
629
|
+
): void => {
|
|
630
|
+
try {
|
|
631
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
632
|
+
attributes[key] = this.truncate(String(value), 2000);
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
// A single unserializable field must not abort the whole enrichment.
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// SQLSTATE (e.g. "23503" = foreign key violation) or a Node error code.
|
|
640
|
+
setStringAttribute("exception.code", error.code ?? databaseError.code);
|
|
641
|
+
setStringAttribute("db.error.detail", databaseError.detail);
|
|
642
|
+
setStringAttribute("db.error.constraint", databaseError.constraint);
|
|
643
|
+
setStringAttribute("db.error.table", databaseError.table);
|
|
644
|
+
setStringAttribute("db.error.column", databaseError.column);
|
|
645
|
+
setStringAttribute("db.error.schema", databaseError.schema);
|
|
646
|
+
|
|
647
|
+
if (typeof error.query === "string" && error.query.length > 0) {
|
|
648
|
+
attributes["db.statement"] = this.truncate(error.query, 2000);
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
/*
|
|
652
|
+
* Reading exotic thrown values (throwing getters, Proxies) must never
|
|
653
|
+
* crash the error-reporting path. Keep whatever was collected so far.
|
|
654
|
+
*/
|
|
655
|
+
if (!attributes["exception.message"]) {
|
|
656
|
+
attributes["exception.message"] =
|
|
657
|
+
"Error (exception details could not be extracted)";
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return attributes;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private static truncate(value: string, maxLength: number): string {
|
|
665
|
+
return value.length > maxLength ? value.substring(0, maxLength) : value;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private static safeStringify(value: unknown): string {
|
|
669
|
+
try {
|
|
670
|
+
const serialized: string | undefined = JSON.stringify(value);
|
|
671
|
+
if (serialized) {
|
|
672
|
+
return serialized;
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
// fall through to String() below
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
return String(value);
|
|
680
|
+
} catch {
|
|
681
|
+
return "[unserializable error value]";
|
|
682
|
+
}
|
|
536
683
|
}
|
|
537
684
|
|
|
538
685
|
public static endSpan(span: Span): void {
|
|
@@ -232,20 +232,33 @@ describe("ClickHouse cluster-aware schema (always-on)", () => {
|
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
-
test("schema ALTERs target the local table", () => {
|
|
235
|
+
test("schema ALTERs target the local table ON CLUSTER", () => {
|
|
236
236
|
const column: AnalyticsTableColumn = new SpanModel().tableColumns[1]!;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
);
|
|
246
|
-
expect(
|
|
247
|
-
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* Column / skip-index ADD + DROP must carry ON CLUSTER: the local storage
|
|
240
|
+
* table is Replicated per shard, and Keeper only replicates within a
|
|
241
|
+
* shard. Without ON CLUSTER a reconciled column/index lands on a single
|
|
242
|
+
* shard, so a scatter-gather read through the Distributed wrapper hits a
|
|
243
|
+
* shard that lacks it and fails with "Missing columns" (Code 47).
|
|
244
|
+
*/
|
|
245
|
+
const addColumn: string = fullText(spanGen.toAddColumnStatement(column));
|
|
246
|
+
expect(addColumn).toContain("SpanItemV3Local");
|
|
247
|
+
expect(addColumn).toContain("ON CLUSTER 'oneuptime'");
|
|
248
|
+
|
|
249
|
+
const addIndex: string = fullText(
|
|
250
|
+
spanGen.toAddSkipIndexStatement(column)!,
|
|
248
251
|
);
|
|
252
|
+
expect(addIndex).toContain("SpanItemV3Local");
|
|
253
|
+
expect(addIndex).toContain("ON CLUSTER 'oneuptime'");
|
|
254
|
+
|
|
255
|
+
const dropColumn: string = spanGen.toDropColumnStatement("traceId");
|
|
256
|
+
expect(dropColumn).toContain("SpanItemV3Local");
|
|
257
|
+
expect(dropColumn).toContain("ON CLUSTER 'oneuptime'");
|
|
258
|
+
|
|
259
|
+
const dropIndex: string = spanGen.toDropSkipIndexStatement("idx_trace_id");
|
|
260
|
+
expect(dropIndex).toContain("SpanItemV3Local");
|
|
261
|
+
expect(dropIndex).toContain("ON CLUSTER 'oneuptime'");
|
|
249
262
|
});
|
|
250
263
|
|
|
251
264
|
test("ALTER UPDATE mutates the local table with ON CLUSTER", () => {
|