@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import LlmSpanUtil, {
|
|
2
|
+
LlmSpanFields,
|
|
3
|
+
} from "../../../../Server/Utils/Telemetry/LlmSpan";
|
|
4
|
+
import { AttributeType } from "../../../../Server/Utils/Telemetry/Telemetry";
|
|
5
|
+
import Dictionary from "../../../../Types/Dictionary";
|
|
6
|
+
import { describe, expect, test } from "@jest/globals";
|
|
7
|
+
|
|
8
|
+
type Attrs = Dictionary<AttributeType | Array<AttributeType>>;
|
|
9
|
+
|
|
10
|
+
describe("LlmSpanUtil.extract", () => {
|
|
11
|
+
test("non-LLM span returns empty/default fields", () => {
|
|
12
|
+
const attrs: Attrs = {
|
|
13
|
+
"http.method": "GET",
|
|
14
|
+
"http.route": "/api/users",
|
|
15
|
+
"db.system": "postgresql",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
19
|
+
|
|
20
|
+
expect(fields.isLlmSpan).toBe(false);
|
|
21
|
+
expect(fields.llmSystem).toBe("");
|
|
22
|
+
expect(fields.llmTotalTokens).toBe(0);
|
|
23
|
+
expect(fields.llmCost).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("empty attributes returns defaults", () => {
|
|
27
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract({});
|
|
28
|
+
expect(fields).toEqual(LlmSpanUtil.empty());
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("OTel GenAI conventions (chat completion)", () => {
|
|
32
|
+
const attrs: Attrs = {
|
|
33
|
+
"gen_ai.system": "openai",
|
|
34
|
+
"gen_ai.operation.name": "chat",
|
|
35
|
+
"gen_ai.request.model": "gpt-4o",
|
|
36
|
+
"gen_ai.response.model": "gpt-4o-2024-08-06",
|
|
37
|
+
"gen_ai.usage.input_tokens": 1200,
|
|
38
|
+
"gen_ai.usage.output_tokens": 350,
|
|
39
|
+
"gen_ai.usage.cost": 0.0185,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
43
|
+
|
|
44
|
+
expect(fields.isLlmSpan).toBe(true);
|
|
45
|
+
expect(fields.llmSystem).toBe("openai");
|
|
46
|
+
expect(fields.llmOperation).toBe("chat");
|
|
47
|
+
expect(fields.llmRequestModel).toBe("gpt-4o");
|
|
48
|
+
expect(fields.llmResponseModel).toBe("gpt-4o-2024-08-06");
|
|
49
|
+
expect(fields.llmInputTokens).toBe(1200);
|
|
50
|
+
expect(fields.llmOutputTokens).toBe(350);
|
|
51
|
+
// total derived from input + output when not reported.
|
|
52
|
+
expect(fields.llmTotalTokens).toBe(1550);
|
|
53
|
+
expect(fields.llmCost).toBe(0.0185);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("explicit total_tokens is preferred over derived sum", () => {
|
|
57
|
+
const attrs: Attrs = {
|
|
58
|
+
"gen_ai.system": "anthropic",
|
|
59
|
+
"gen_ai.request.model": "claude-opus-4-8",
|
|
60
|
+
"gen_ai.usage.input_tokens": 100,
|
|
61
|
+
"gen_ai.usage.output_tokens": 50,
|
|
62
|
+
"gen_ai.usage.total_tokens": 999,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
66
|
+
expect(fields.llmTotalTokens).toBe(999);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("OpenLLMetry legacy prompt/completion token aliases", () => {
|
|
70
|
+
const attrs: Attrs = {
|
|
71
|
+
"gen_ai.system": "anthropic",
|
|
72
|
+
"gen_ai.request.model": "claude-sonnet-4-6",
|
|
73
|
+
"gen_ai.usage.prompt_tokens": 80,
|
|
74
|
+
"gen_ai.usage.completion_tokens": 20,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
78
|
+
expect(fields.isLlmSpan).toBe(true);
|
|
79
|
+
expect(fields.llmInputTokens).toBe(80);
|
|
80
|
+
expect(fields.llmOutputTokens).toBe(20);
|
|
81
|
+
expect(fields.llmTotalTokens).toBe(100);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("OpenInference (llm.* + llm.token_count.*) conventions", () => {
|
|
85
|
+
const attrs: Attrs = {
|
|
86
|
+
"openinference.span.kind": "LLM",
|
|
87
|
+
"llm.system": "openai",
|
|
88
|
+
"llm.model_name": "gpt-4o-mini",
|
|
89
|
+
"llm.token_count.prompt": 500,
|
|
90
|
+
"llm.token_count.completion": 120,
|
|
91
|
+
"llm.token_count.total": 620,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
95
|
+
expect(fields.isLlmSpan).toBe(true);
|
|
96
|
+
expect(fields.llmSystem).toBe("openai");
|
|
97
|
+
expect(fields.llmOperation).toBe("LLM");
|
|
98
|
+
expect(fields.llmRequestModel).toBe("gpt-4o-mini");
|
|
99
|
+
expect(fields.llmInputTokens).toBe(500);
|
|
100
|
+
expect(fields.llmOutputTokens).toBe(120);
|
|
101
|
+
expect(fields.llmTotalTokens).toBe(620);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("numeric token values reported as strings are coerced", () => {
|
|
105
|
+
const attrs: Attrs = {
|
|
106
|
+
"gen_ai.system": "openai",
|
|
107
|
+
"gen_ai.usage.input_tokens": "42",
|
|
108
|
+
"gen_ai.usage.output_tokens": "8",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
112
|
+
expect(fields.llmInputTokens).toBe(42);
|
|
113
|
+
expect(fields.llmOutputTokens).toBe(8);
|
|
114
|
+
expect(fields.llmTotalTokens).toBe(50);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("agent + tool spans are detected", () => {
|
|
118
|
+
const agentAttrs: Attrs = {
|
|
119
|
+
"gen_ai.operation.name": "invoke_agent",
|
|
120
|
+
"gen_ai.agent.name": "research-agent",
|
|
121
|
+
};
|
|
122
|
+
const agentFields: LlmSpanFields = LlmSpanUtil.extract(agentAttrs);
|
|
123
|
+
expect(agentFields.isLlmSpan).toBe(true);
|
|
124
|
+
expect(agentFields.llmAgentName).toBe("research-agent");
|
|
125
|
+
expect(agentFields.llmOperation).toBe("invoke_agent");
|
|
126
|
+
|
|
127
|
+
const toolAttrs: Attrs = {
|
|
128
|
+
"gen_ai.operation.name": "execute_tool",
|
|
129
|
+
"gen_ai.tool.name": "web_search",
|
|
130
|
+
};
|
|
131
|
+
const toolFields: LlmSpanFields = LlmSpanUtil.extract(toolAttrs);
|
|
132
|
+
expect(toolFields.isLlmSpan).toBe(true);
|
|
133
|
+
expect(toolFields.llmToolName).toBe("web_search");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("response model is used as request model fallback", () => {
|
|
137
|
+
const attrs: Attrs = {
|
|
138
|
+
"gen_ai.system": "openai",
|
|
139
|
+
"gen_ai.response.model": "gpt-4o-2024-08-06",
|
|
140
|
+
};
|
|
141
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
142
|
+
expect(fields.llmRequestModel).toBe("gpt-4o-2024-08-06");
|
|
143
|
+
expect(fields.llmResponseModel).toBe("gpt-4o-2024-08-06");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("bare gen_ai-namespaced attribute still flags as LLM span", () => {
|
|
147
|
+
const attrs: Attrs = {
|
|
148
|
+
"gen_ai.request.temperature": 0.7,
|
|
149
|
+
};
|
|
150
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
151
|
+
expect(fields.isLlmSpan).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("array-valued attributes do not break string/number extraction", () => {
|
|
155
|
+
const attrs: Attrs = {
|
|
156
|
+
"gen_ai.system": "openai",
|
|
157
|
+
"gen_ai.response.finish_reasons": ["stop"],
|
|
158
|
+
"gen_ai.usage.input_tokens": 10,
|
|
159
|
+
};
|
|
160
|
+
const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
|
|
161
|
+
expect(fields.isLlmSpan).toBe(true);
|
|
162
|
+
expect(fields.llmInputTokens).toBe(10);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import Telemetry, {
|
|
2
|
+
Span,
|
|
3
|
+
SpanStatusCode,
|
|
4
|
+
} from "../../../Server/Utils/Telemetry";
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
beforeAll,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
jest,
|
|
11
|
+
test,
|
|
12
|
+
} from "@jest/globals";
|
|
13
|
+
|
|
14
|
+
type ExceptionAttributes = Record<string, string | undefined>;
|
|
15
|
+
|
|
16
|
+
// getExceptionAttributes is a private static; reach it through a narrow cast.
|
|
17
|
+
function getAttributes(exception: unknown): ExceptionAttributes {
|
|
18
|
+
return (
|
|
19
|
+
Telemetry as unknown as {
|
|
20
|
+
getExceptionAttributes: (e: unknown) => ExceptionAttributes;
|
|
21
|
+
}
|
|
22
|
+
).getExceptionAttributes(exception);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("Telemetry.getExceptionAttributes", () => {
|
|
26
|
+
test("extracts Postgres fields from a TypeORM QueryFailedError-shaped error", () => {
|
|
27
|
+
const error: Error = Object.assign(
|
|
28
|
+
new Error('delete on "Project" violates foreign key constraint'),
|
|
29
|
+
{
|
|
30
|
+
driverError: {
|
|
31
|
+
code: "23503",
|
|
32
|
+
detail: 'Key (id)=(abc) is still referenced from table "Monitor".',
|
|
33
|
+
constraint: "FK_monitor_project",
|
|
34
|
+
table: "Monitor",
|
|
35
|
+
column: "projectId",
|
|
36
|
+
schema: "public",
|
|
37
|
+
},
|
|
38
|
+
query: 'DELETE FROM "Project" WHERE "_id" IN ($1)',
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const attributes: ExceptionAttributes = getAttributes(error);
|
|
43
|
+
|
|
44
|
+
expect(attributes["exception.code"]).toBe("23503");
|
|
45
|
+
expect(attributes["db.error.constraint"]).toBe("FK_monitor_project");
|
|
46
|
+
expect(attributes["db.error.table"]).toBe("Monitor");
|
|
47
|
+
expect(attributes["db.error.column"]).toBe("projectId");
|
|
48
|
+
expect(attributes["db.error.schema"]).toBe("public");
|
|
49
|
+
expect(attributes["db.error.detail"]).toContain("still referenced");
|
|
50
|
+
expect(attributes["db.statement"]).toBe(
|
|
51
|
+
'DELETE FROM "Project" WHERE "_id" IN ($1)',
|
|
52
|
+
);
|
|
53
|
+
expect(attributes["exception.type"]).toBe("Error");
|
|
54
|
+
expect(attributes["exception.message"]).toContain("violates foreign key");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("reads a top-level pg error code when there is no driverError", () => {
|
|
58
|
+
const error: Error = Object.assign(
|
|
59
|
+
new Error("duplicate key value violates unique constraint"),
|
|
60
|
+
{ code: "23505", constraint: "uniq_email" },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const attributes: ExceptionAttributes = getAttributes(error);
|
|
64
|
+
|
|
65
|
+
expect(attributes["exception.code"]).toBe("23505");
|
|
66
|
+
expect(attributes["db.error.constraint"]).toBe("uniq_email");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("captures type, message and stacktrace for a plain Error", () => {
|
|
70
|
+
const attributes: ExceptionAttributes = getAttributes(new Error("boom"));
|
|
71
|
+
|
|
72
|
+
expect(attributes["exception.type"]).toBe("Error");
|
|
73
|
+
expect(attributes["exception.message"]).toBe("boom");
|
|
74
|
+
expect(typeof attributes["exception.stacktrace"]).toBe("string");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("handles a thrown string", () => {
|
|
78
|
+
const attributes: ExceptionAttributes = getAttributes("kaboom");
|
|
79
|
+
|
|
80
|
+
expect(attributes["exception.message"]).toBe("kaboom");
|
|
81
|
+
expect(attributes["exception.type"]).toBeUndefined();
|
|
82
|
+
expect(attributes["db.error.constraint"]).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("handles null and undefined throws", () => {
|
|
86
|
+
expect(getAttributes(null)["exception.message"]).toBe(
|
|
87
|
+
"Unknown error: null or undefined was thrown",
|
|
88
|
+
);
|
|
89
|
+
expect(getAttributes(undefined)["exception.message"]).toBe(
|
|
90
|
+
"Unknown error: null or undefined was thrown",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("serializes a non-Error object throw into the message", () => {
|
|
95
|
+
const attributes: ExceptionAttributes = getAttributes({ a: 1, b: "x" });
|
|
96
|
+
|
|
97
|
+
expect(attributes["exception.message"]).toBe('{"a":1,"b":"x"}');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("truncates an oversized message and SQL statement", () => {
|
|
101
|
+
const error: Error = Object.assign(new Error("x".repeat(9000)), {
|
|
102
|
+
query: `SELECT ${"a".repeat(9000)}`,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const attributes: ExceptionAttributes = getAttributes(error);
|
|
106
|
+
|
|
107
|
+
expect(attributes["exception.message"]?.length).toBe(4000);
|
|
108
|
+
expect(attributes["db.statement"]?.length).toBe(2000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- crash-safety: hostile thrown values must never make this throw. ---
|
|
112
|
+
|
|
113
|
+
test("does not throw on an object with throwing getters", () => {
|
|
114
|
+
const hostile: Record<string, unknown> = {};
|
|
115
|
+
Object.defineProperty(hostile, "code", {
|
|
116
|
+
enumerable: true,
|
|
117
|
+
get: (): never => {
|
|
118
|
+
throw new Error("getter blew up");
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
Object.defineProperty(hostile, "driverError", {
|
|
122
|
+
enumerable: true,
|
|
123
|
+
get: (): never => {
|
|
124
|
+
throw new Error("driverError blew up");
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let attributes: ExceptionAttributes = {};
|
|
129
|
+
expect((): void => {
|
|
130
|
+
attributes = getAttributes(hostile);
|
|
131
|
+
}).not.toThrow();
|
|
132
|
+
expect(typeof attributes["exception.message"]).toBe("string");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does not throw on a field whose toString throws (good fields survive)", () => {
|
|
136
|
+
const error: Error = Object.assign(new Error("db fail"), {
|
|
137
|
+
driverError: {
|
|
138
|
+
detail: {
|
|
139
|
+
toString: (): never => {
|
|
140
|
+
throw new Error("toString blew up");
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect((): ExceptionAttributes => {
|
|
147
|
+
return getAttributes(error);
|
|
148
|
+
}).not.toThrow();
|
|
149
|
+
expect(getAttributes(error)["exception.message"]).toBe("db fail");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("does not throw on a Proxy that traps every property read", () => {
|
|
153
|
+
const hostile: unknown = new Proxy(
|
|
154
|
+
{},
|
|
155
|
+
{
|
|
156
|
+
get: (): never => {
|
|
157
|
+
throw new Error("proxy trap");
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect((): ExceptionAttributes => {
|
|
163
|
+
return getAttributes(hostile);
|
|
164
|
+
}).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan", () => {
|
|
169
|
+
type FakeSpanState = {
|
|
170
|
+
attributes: Record<string, unknown> | null;
|
|
171
|
+
status: { code: number; message?: string } | null;
|
|
172
|
+
recorded: unknown;
|
|
173
|
+
ended: number;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Silence the logger.error console output these tests intentionally trigger.
|
|
177
|
+
beforeAll((): void => {
|
|
178
|
+
jest.spyOn(console, "error").mockImplementation((): void => {
|
|
179
|
+
return undefined;
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
afterAll((): void => {
|
|
184
|
+
jest.restoreAllMocks();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function makeFakeSpan(
|
|
188
|
+
state: FakeSpanState,
|
|
189
|
+
opts?: { throwOnSetAttributes?: boolean },
|
|
190
|
+
): Span {
|
|
191
|
+
return {
|
|
192
|
+
setAttributes: (a: Record<string, unknown>): unknown => {
|
|
193
|
+
if (opts?.throwOnSetAttributes) {
|
|
194
|
+
throw new Error("setAttributes blew up");
|
|
195
|
+
}
|
|
196
|
+
state.attributes = a;
|
|
197
|
+
return undefined;
|
|
198
|
+
},
|
|
199
|
+
recordException: (e: unknown): unknown => {
|
|
200
|
+
state.recorded = e;
|
|
201
|
+
return undefined;
|
|
202
|
+
},
|
|
203
|
+
setStatus: (s: { code: number; message?: string }): unknown => {
|
|
204
|
+
state.status = s;
|
|
205
|
+
return undefined;
|
|
206
|
+
},
|
|
207
|
+
end: (): void => {
|
|
208
|
+
state.ended += 1;
|
|
209
|
+
},
|
|
210
|
+
} as unknown as Span;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test("marks the span as error with a message and ends it", () => {
|
|
214
|
+
const state: FakeSpanState = {
|
|
215
|
+
attributes: null,
|
|
216
|
+
status: null,
|
|
217
|
+
recorded: null,
|
|
218
|
+
ended: 0,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
|
|
222
|
+
span: makeFakeSpan(state),
|
|
223
|
+
exception: new Error("kaboom"),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(state.ended).toBe(1);
|
|
227
|
+
expect(state.status?.code).toBe(SpanStatusCode.ERROR);
|
|
228
|
+
expect(state.status?.message).toBe("kaboom");
|
|
229
|
+
expect((state.attributes || {})["exception.message"]).toBe("kaboom");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("still ends and flags the span even if a span write throws", () => {
|
|
233
|
+
const state: FakeSpanState = {
|
|
234
|
+
attributes: null,
|
|
235
|
+
status: null,
|
|
236
|
+
recorded: null,
|
|
237
|
+
ended: 0,
|
|
238
|
+
};
|
|
239
|
+
const span: Span = makeFakeSpan(state, { throwOnSetAttributes: true });
|
|
240
|
+
|
|
241
|
+
expect((): void => {
|
|
242
|
+
Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
|
|
243
|
+
span,
|
|
244
|
+
exception: new Error("kaboom"),
|
|
245
|
+
});
|
|
246
|
+
}).not.toThrow();
|
|
247
|
+
|
|
248
|
+
expect(state.ended).toBe(1);
|
|
249
|
+
expect(state.status?.code).toBe(SpanStatusCode.ERROR);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -16,10 +16,15 @@ export default interface DashboardTraceChartComponent extends BaseComponent {
|
|
|
16
16
|
// Substring filter on span name (e.g. "/Shipment/ShipShipment").
|
|
17
17
|
spanNameContains?: string | undefined;
|
|
18
18
|
/*
|
|
19
|
-
* Attribute equality filters, ANDed
|
|
20
|
-
* (e.g. "url.host
|
|
19
|
+
* Attribute equality filters, ANDed. The structured editor stores a
|
|
20
|
+
* key/value record (e.g. { "url.host": "torginol.starship.online" }).
|
|
21
|
+
* A legacy "key=value; key2=value2" string is still read for widgets
|
|
22
|
+
* saved before the structured editor existed.
|
|
21
23
|
*/
|
|
22
|
-
attributeFilters?:
|
|
24
|
+
attributeFilters?:
|
|
25
|
+
| string
|
|
26
|
+
| Record<string, string | number | boolean>
|
|
27
|
+
| undefined;
|
|
23
28
|
/*
|
|
24
29
|
* Optional split dimension: a span attribute key (e.g. url.host,
|
|
25
30
|
* resource.service.instance.id) or a top-level column (name,
|
|
@@ -4,6 +4,7 @@ import React, {
|
|
|
4
4
|
useState,
|
|
5
5
|
useEffect,
|
|
6
6
|
} from "react";
|
|
7
|
+
import { createPortal } from "react-dom";
|
|
7
8
|
import Route from "../../../Types/API/Route";
|
|
8
9
|
import URL from "../../../Types/API/URL";
|
|
9
10
|
import IconProp from "../../../Types/Icon/IconProp";
|
|
@@ -368,9 +369,18 @@ const Navbar: FunctionComponent<ComponentProps> = (
|
|
|
368
369
|
</div>
|
|
369
370
|
)}
|
|
370
371
|
|
|
372
|
+
{/*
|
|
373
|
+
* Render the full-screen products modal in a portal on document.body so it
|
|
374
|
+
* is not a flex child of this `justify-between` nav. Otherwise it counts as
|
|
375
|
+
* a third flex item and shifts the right-side user/settings menu toward the
|
|
376
|
+
* middle whenever the modal opens. The modal is `fixed inset-0`, so its
|
|
377
|
+
* visual position is identical when portaled.
|
|
378
|
+
*/}
|
|
371
379
|
{isMoreMenuVisible &&
|
|
372
380
|
props.moreMenuItems &&
|
|
373
|
-
props.moreMenuItems.length > 0 &&
|
|
381
|
+
props.moreMenuItems.length > 0 &&
|
|
382
|
+
typeof document !== "undefined" &&
|
|
383
|
+
createPortal(
|
|
374
384
|
<NavBarMenuModal
|
|
375
385
|
items={props.moreMenuItems}
|
|
376
386
|
footer={props.moreMenuFooter}
|
|
@@ -379,7 +389,8 @@ const Navbar: FunctionComponent<ComponentProps> = (
|
|
|
379
389
|
keyboardHint={props.moreMenuKeyboardHint}
|
|
380
390
|
recentLabel={props.moreMenuRecentLabel}
|
|
381
391
|
onClose={closeMoreMenu}
|
|
382
|
-
|
|
392
|
+
/>,
|
|
393
|
+
document.body,
|
|
383
394
|
)}
|
|
384
395
|
</nav>
|
|
385
396
|
);
|
|
@@ -15,11 +15,12 @@ const DisplaySection: ComponentArgumentSection = {
|
|
|
15
15
|
order: 1,
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
/*
|
|
19
|
+
* The "Query" section (span-name filter, attribute filters, split-by, max
|
|
20
|
+
* series, include-child-spans) is rendered by a bespoke, structured editor —
|
|
21
|
+
* TraceChartQueryEditor — rather than these declarative free-text fields, so
|
|
22
|
+
* those arguments are deliberately not listed here. See ArgumentsForm.tsx.
|
|
23
|
+
*/
|
|
23
24
|
|
|
24
25
|
export default class DashboardTraceChartComponentUtil extends DashboardBaseComponentUtil {
|
|
25
26
|
public static override getDefaultComponent(): DashboardTraceChartComponent {
|
|
@@ -76,59 +77,6 @@ export default class DashboardTraceChartComponentUtil extends DashboardBaseCompo
|
|
|
76
77
|
],
|
|
77
78
|
});
|
|
78
79
|
|
|
79
|
-
componentArguments.push({
|
|
80
|
-
name: "Span Name Contains",
|
|
81
|
-
description:
|
|
82
|
-
"Only include spans whose name contains this text (e.g. /Shipment/ShipShipment)",
|
|
83
|
-
required: false,
|
|
84
|
-
type: ComponentInputType.Text,
|
|
85
|
-
id: "spanNameContains",
|
|
86
|
-
placeholder: "/Shipment/ShipShipment",
|
|
87
|
-
section: QuerySection,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
componentArguments.push({
|
|
91
|
-
name: "Attribute Filters",
|
|
92
|
-
description:
|
|
93
|
-
"Attribute equality filters, ANDed — key=value pairs separated by semicolons (e.g. url.host=torginol.starship.online; http.method=POST)",
|
|
94
|
-
required: false,
|
|
95
|
-
type: ComponentInputType.Text,
|
|
96
|
-
id: "attributeFilters",
|
|
97
|
-
placeholder: "url.host=torginol.starship.online",
|
|
98
|
-
section: QuerySection,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
componentArguments.push({
|
|
102
|
-
name: "Split By",
|
|
103
|
-
description:
|
|
104
|
-
"Optional dimension — a span attribute key (e.g. url.host, resource.service.instance.id) or one of: name, primaryEntityId, statusCode, kind. One series per value.",
|
|
105
|
-
required: false,
|
|
106
|
-
type: ComponentInputType.Text,
|
|
107
|
-
id: "groupByAttribute",
|
|
108
|
-
placeholder: "url.host",
|
|
109
|
-
section: QuerySection,
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
componentArguments.push({
|
|
113
|
-
name: "Top Series",
|
|
114
|
-
description: "Cap on the number of series when split (default 10)",
|
|
115
|
-
required: false,
|
|
116
|
-
type: ComponentInputType.Number,
|
|
117
|
-
id: "topLimit",
|
|
118
|
-
placeholder: "10",
|
|
119
|
-
section: QuerySection,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
componentArguments.push({
|
|
123
|
-
name: "Include Child Spans",
|
|
124
|
-
description:
|
|
125
|
-
"Count every span instead of root spans only (the traces explorer defaults to root spans)",
|
|
126
|
-
required: false,
|
|
127
|
-
type: ComponentInputType.Boolean,
|
|
128
|
-
id: "includeChildSpans",
|
|
129
|
-
section: QuerySection,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
80
|
return componentArguments;
|
|
133
81
|
}
|
|
134
82
|
}
|