@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
|
@@ -524,17 +524,108 @@ export default class Telemetry {
|
|
|
524
524
|
}): void {
|
|
525
525
|
const { span, exception } = data;
|
|
526
526
|
|
|
527
|
+
const exceptionAttributes: Attributes =
|
|
528
|
+
this.getExceptionAttributes(exception);
|
|
529
|
+
|
|
527
530
|
// log the exception as well
|
|
528
531
|
logger.error(exception);
|
|
529
532
|
|
|
533
|
+
/*
|
|
534
|
+
* Span *events* (from recordException) are not reliably surfaced when the
|
|
535
|
+
* span is read back, and setStatus on its own only records the error CODE,
|
|
536
|
+
* not the message. So we also attach the exception details as queryable
|
|
537
|
+
* span attributes — including DB driver fields like the failing constraint
|
|
538
|
+
* and table — so the actual cause is visible in the trace UI instead of an
|
|
539
|
+
* empty "Error" status.
|
|
540
|
+
*/
|
|
541
|
+
span.setAttributes(exceptionAttributes);
|
|
530
542
|
span.recordException(exception as SpanException);
|
|
531
543
|
span.setStatus({
|
|
532
544
|
code: SpanStatusCode.ERROR,
|
|
545
|
+
message:
|
|
546
|
+
(exceptionAttributes["exception.message"] as string | undefined) ||
|
|
547
|
+
"Error",
|
|
533
548
|
});
|
|
534
549
|
|
|
535
550
|
this.endSpan(span);
|
|
536
551
|
}
|
|
537
552
|
|
|
553
|
+
/*
|
|
554
|
+
* Pulls every useful field off an unknown thrown value into OpenTelemetry
|
|
555
|
+
* span attributes. Error message/stack are non-enumerable, so they are read
|
|
556
|
+
* explicitly. For database failures (TypeORM QueryFailedError / pg errors),
|
|
557
|
+
* the Postgres fields (SQLSTATE code, detail, constraint, table, column) live
|
|
558
|
+
* either on the error itself or on `driverError`; these are what tell us which
|
|
559
|
+
* constraint failed during e.g. a cascade delete.
|
|
560
|
+
*/
|
|
561
|
+
private static getExceptionAttributes(exception: unknown): Attributes {
|
|
562
|
+
const attributes: Attributes = {};
|
|
563
|
+
|
|
564
|
+
if (exception === null || exception === undefined) {
|
|
565
|
+
attributes["exception.message"] =
|
|
566
|
+
"Unknown error: null or undefined was thrown";
|
|
567
|
+
return attributes;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (exception instanceof Error) {
|
|
571
|
+
attributes["exception.type"] =
|
|
572
|
+
exception.name || exception.constructor?.name || "Error";
|
|
573
|
+
attributes["exception.message"] = exception.message || "";
|
|
574
|
+
if (exception.stack) {
|
|
575
|
+
attributes["exception.stacktrace"] = exception.stack.substring(0, 8000);
|
|
576
|
+
}
|
|
577
|
+
} else if (typeof exception === "string") {
|
|
578
|
+
attributes["exception.message"] = exception;
|
|
579
|
+
} else {
|
|
580
|
+
attributes["exception.message"] = this.safeStringify(exception);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
type PotentialDatabaseError = {
|
|
584
|
+
code?: unknown;
|
|
585
|
+
detail?: unknown;
|
|
586
|
+
constraint?: unknown;
|
|
587
|
+
table?: unknown;
|
|
588
|
+
column?: unknown;
|
|
589
|
+
schema?: unknown;
|
|
590
|
+
query?: unknown;
|
|
591
|
+
driverError?: PotentialDatabaseError;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const error: PotentialDatabaseError = exception as PotentialDatabaseError;
|
|
595
|
+
const databaseError: PotentialDatabaseError = error.driverError || error;
|
|
596
|
+
|
|
597
|
+
const setStringAttribute: (key: string, value: unknown) => void = (
|
|
598
|
+
key: string,
|
|
599
|
+
value: unknown,
|
|
600
|
+
): void => {
|
|
601
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
602
|
+
attributes[key] = String(value);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// SQLSTATE (e.g. "23503" = foreign key violation) or a Node error code.
|
|
607
|
+
setStringAttribute("exception.code", error.code ?? databaseError.code);
|
|
608
|
+
setStringAttribute("db.error.detail", databaseError.detail);
|
|
609
|
+
setStringAttribute("db.error.constraint", databaseError.constraint);
|
|
610
|
+
setStringAttribute("db.error.table", databaseError.table);
|
|
611
|
+
setStringAttribute("db.error.column", databaseError.column);
|
|
612
|
+
setStringAttribute("db.error.schema", databaseError.schema);
|
|
613
|
+
|
|
614
|
+
if (typeof error.query === "string" && error.query.length > 0) {
|
|
615
|
+
attributes["db.statement"] = error.query.substring(0, 2000);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return attributes;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private static safeStringify(value: unknown): string {
|
|
622
|
+
try {
|
|
623
|
+
return JSON.stringify(value) || String(value);
|
|
624
|
+
} catch {
|
|
625
|
+
return String(value);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
538
629
|
public static endSpan(span: Span): void {
|
|
539
630
|
span.end();
|
|
540
631
|
}
|
|
@@ -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,44 @@
|
|
|
1
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
2
|
+
import Currency from "../../Types/Currency";
|
|
3
|
+
|
|
4
|
+
describe("Currency", () => {
|
|
5
|
+
describe("convertToDecimalPlaces", () => {
|
|
6
|
+
test("rounds to two decimal places by default", () => {
|
|
7
|
+
expect(Currency.convertToDecimalPlaces(1.236)).toBe(1.24);
|
|
8
|
+
expect(Currency.convertToDecimalPlaces(1.231)).toBe(1.23);
|
|
9
|
+
expect(Currency.convertToDecimalPlaces(3.14159)).toBe(3.14);
|
|
10
|
+
expect(Currency.convertToDecimalPlaces(99.994)).toBe(99.99);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("leaves values without fractional digits unchanged", () => {
|
|
14
|
+
expect(Currency.convertToDecimalPlaces(0)).toBe(0);
|
|
15
|
+
expect(Currency.convertToDecimalPlaces(100)).toBe(100);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("rounds negative values", () => {
|
|
19
|
+
expect(Currency.convertToDecimalPlaces(-1.236)).toBe(-1.24);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("honours a custom number of decimal places", () => {
|
|
23
|
+
expect(Currency.convertToDecimalPlaces(1.23456, 3)).toBe(1.235);
|
|
24
|
+
expect(Currency.convertToDecimalPlaces(1.26, 1)).toBe(1.3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("ceils the value when decimalPlaces is 0", () => {
|
|
28
|
+
expect(Currency.convertToDecimalPlaces(1.1, 0)).toBe(2);
|
|
29
|
+
expect(Currency.convertToDecimalPlaces(5, 0)).toBe(5);
|
|
30
|
+
expect(Currency.convertToDecimalPlaces(4.0001, 0)).toBe(5);
|
|
31
|
+
expect(Currency.convertToDecimalPlaces(-2.5, 0)).toBe(-2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("throws BadDataException when decimalPlaces is negative", () => {
|
|
35
|
+
expect(() => {
|
|
36
|
+
Currency.convertToDecimalPlaces(1.5, -1);
|
|
37
|
+
}).toThrowError(BadDataException);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns a number", () => {
|
|
41
|
+
expect(typeof Currency.convertToDecimalPlaces(1.236)).toBe("number");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
2
|
+
import MonitorType, {
|
|
3
|
+
MonitorTypeCategory,
|
|
4
|
+
MonitorTypeHelper,
|
|
5
|
+
MonitorTypeProps,
|
|
6
|
+
} from "../../../Types/Monitor/MonitorType";
|
|
7
|
+
|
|
8
|
+
describe("MonitorTypeHelper", () => {
|
|
9
|
+
describe("getMonitorTypeCategories", () => {
|
|
10
|
+
test("returns a non-empty list of categories with labels and types", () => {
|
|
11
|
+
const categories: Array<MonitorTypeCategory> =
|
|
12
|
+
MonitorTypeHelper.getMonitorTypeCategories();
|
|
13
|
+
|
|
14
|
+
expect(Array.isArray(categories)).toBe(true);
|
|
15
|
+
expect(categories.length).toBeGreaterThan(0);
|
|
16
|
+
|
|
17
|
+
for (const category of categories) {
|
|
18
|
+
expect(typeof category.label).toBe("string");
|
|
19
|
+
expect(category.label.length).toBeGreaterThan(0);
|
|
20
|
+
expect(Array.isArray(category.monitorTypes)).toBe(true);
|
|
21
|
+
expect(category.monitorTypes.length).toBeGreaterThan(0);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("getAllMonitorTypeProps", () => {
|
|
27
|
+
test("returns props with a unique monitorType, title and description each", () => {
|
|
28
|
+
const props: Array<MonitorTypeProps> =
|
|
29
|
+
MonitorTypeHelper.getAllMonitorTypeProps();
|
|
30
|
+
|
|
31
|
+
expect(props.length).toBeGreaterThan(0);
|
|
32
|
+
|
|
33
|
+
const seen: Set<MonitorType> = new Set<MonitorType>();
|
|
34
|
+
for (const prop of props) {
|
|
35
|
+
expect(typeof prop.title).toBe("string");
|
|
36
|
+
expect(prop.title.length).toBeGreaterThan(0);
|
|
37
|
+
expect(typeof prop.description).toBe("string");
|
|
38
|
+
expect(prop.description.length).toBeGreaterThan(0);
|
|
39
|
+
expect(seen.has(prop.monitorType)).toBe(false);
|
|
40
|
+
seen.add(prop.monitorType);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("isTelemetryMonitor", () => {
|
|
46
|
+
test.each([
|
|
47
|
+
MonitorType.Logs,
|
|
48
|
+
MonitorType.Metrics,
|
|
49
|
+
MonitorType.Traces,
|
|
50
|
+
MonitorType.Exceptions,
|
|
51
|
+
MonitorType.Profiles,
|
|
52
|
+
MonitorType.Kubernetes,
|
|
53
|
+
MonitorType.Docker,
|
|
54
|
+
MonitorType.Host,
|
|
55
|
+
MonitorType.Podman,
|
|
56
|
+
MonitorType.DockerSwarm,
|
|
57
|
+
MonitorType.Proxmox,
|
|
58
|
+
MonitorType.Ceph,
|
|
59
|
+
])("returns true for %s", (monitorType: MonitorType) => {
|
|
60
|
+
expect(MonitorTypeHelper.isTelemetryMonitor(monitorType)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test.each([MonitorType.Manual, MonitorType.Website, MonitorType.API])(
|
|
64
|
+
"returns false for %s",
|
|
65
|
+
(monitorType: MonitorType) => {
|
|
66
|
+
expect(MonitorTypeHelper.isTelemetryMonitor(monitorType)).toBe(false);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("isManualMonitor", () => {
|
|
72
|
+
test("returns true only for Manual", () => {
|
|
73
|
+
expect(MonitorTypeHelper.isManualMonitor(MonitorType.Manual)).toBe(true);
|
|
74
|
+
expect(MonitorTypeHelper.isManualMonitor(MonitorType.Website)).toBe(
|
|
75
|
+
false,
|
|
76
|
+
);
|
|
77
|
+
expect(MonitorTypeHelper.isManualMonitor(MonitorType.API)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("getTitle", () => {
|
|
82
|
+
test("returns the configured title", () => {
|
|
83
|
+
expect(MonitorTypeHelper.getTitle(MonitorType.API)).toBe("API");
|
|
84
|
+
expect(MonitorTypeHelper.getTitle(MonitorType.Docker)).toBe(
|
|
85
|
+
"Docker Container",
|
|
86
|
+
);
|
|
87
|
+
expect(MonitorTypeHelper.getTitle(MonitorType.Server)).toBe(
|
|
88
|
+
"Server / VM",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("throws BadDataException for a type without props", () => {
|
|
93
|
+
expect(() => {
|
|
94
|
+
MonitorTypeHelper.getTitle("NonExistent" as MonitorType);
|
|
95
|
+
}).toThrowError(BadDataException);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("getDescription", () => {
|
|
100
|
+
test("returns a non-empty description for a known type", () => {
|
|
101
|
+
expect(
|
|
102
|
+
MonitorTypeHelper.getDescription(MonitorType.Ping).length,
|
|
103
|
+
).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("throws BadDataException for a type without props", () => {
|
|
107
|
+
expect(() => {
|
|
108
|
+
MonitorTypeHelper.getDescription("NonExistent" as MonitorType);
|
|
109
|
+
}).toThrowError(BadDataException);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("isProbableMonitor", () => {
|
|
114
|
+
test.each([
|
|
115
|
+
MonitorType.API,
|
|
116
|
+
MonitorType.Website,
|
|
117
|
+
MonitorType.IP,
|
|
118
|
+
MonitorType.Ping,
|
|
119
|
+
MonitorType.Port,
|
|
120
|
+
MonitorType.SSLCertificate,
|
|
121
|
+
MonitorType.SyntheticMonitor,
|
|
122
|
+
MonitorType.CustomJavaScriptCode,
|
|
123
|
+
MonitorType.SNMP,
|
|
124
|
+
MonitorType.DNS,
|
|
125
|
+
MonitorType.DNSSEC,
|
|
126
|
+
MonitorType.Domain,
|
|
127
|
+
MonitorType.ExternalStatusPage,
|
|
128
|
+
])("returns true for %s", (monitorType: MonitorType) => {
|
|
129
|
+
expect(MonitorTypeHelper.isProbableMonitor(monitorType)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test.each([
|
|
133
|
+
MonitorType.Manual,
|
|
134
|
+
MonitorType.Logs,
|
|
135
|
+
MonitorType.Server,
|
|
136
|
+
MonitorType.IncomingRequest,
|
|
137
|
+
])("returns false for %s", (monitorType: MonitorType) => {
|
|
138
|
+
expect(MonitorTypeHelper.isProbableMonitor(monitorType)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("doesMonitorTypeHaveInterval", () => {
|
|
143
|
+
test("mirrors isProbableMonitor", () => {
|
|
144
|
+
const types: Array<MonitorType> = [
|
|
145
|
+
MonitorType.API,
|
|
146
|
+
MonitorType.Manual,
|
|
147
|
+
MonitorType.Logs,
|
|
148
|
+
MonitorType.DNS,
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
for (const monitorType of types) {
|
|
152
|
+
expect(MonitorTypeHelper.doesMonitorTypeHaveInterval(monitorType)).toBe(
|
|
153
|
+
MonitorTypeHelper.isProbableMonitor(monitorType),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("getActiveMonitorTypes", () => {
|
|
160
|
+
test("includes Server and excludes Manual", () => {
|
|
161
|
+
const active: Array<MonitorType> =
|
|
162
|
+
MonitorTypeHelper.getActiveMonitorTypes();
|
|
163
|
+
|
|
164
|
+
expect(active).toContain(MonitorType.Server);
|
|
165
|
+
expect(active).not.toContain(MonitorType.Manual);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("doesMonitorTypeHaveDocumentation", () => {
|
|
170
|
+
test.each([
|
|
171
|
+
MonitorType.IncomingRequest,
|
|
172
|
+
MonitorType.IncomingEmail,
|
|
173
|
+
MonitorType.Server,
|
|
174
|
+
])("returns true for %s", (monitorType: MonitorType) => {
|
|
175
|
+
expect(
|
|
176
|
+
MonitorTypeHelper.doesMonitorTypeHaveDocumentation(monitorType),
|
|
177
|
+
).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("returns false for other types", () => {
|
|
181
|
+
expect(
|
|
182
|
+
MonitorTypeHelper.doesMonitorTypeHaveDocumentation(MonitorType.API),
|
|
183
|
+
).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("doesMonitorTypeHaveCriteria", () => {
|
|
188
|
+
test("returns false only for Manual", () => {
|
|
189
|
+
expect(
|
|
190
|
+
MonitorTypeHelper.doesMonitorTypeHaveCriteria(MonitorType.Manual),
|
|
191
|
+
).toBe(false);
|
|
192
|
+
expect(
|
|
193
|
+
MonitorTypeHelper.doesMonitorTypeHaveCriteria(MonitorType.API),
|
|
194
|
+
).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("doesMonitorTypeHaveGraphs", () => {
|
|
199
|
+
test("returns true for graphable types and false otherwise", () => {
|
|
200
|
+
expect(
|
|
201
|
+
MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Website),
|
|
202
|
+
).toBe(true);
|
|
203
|
+
expect(
|
|
204
|
+
MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Server),
|
|
205
|
+
).toBe(true);
|
|
206
|
+
expect(
|
|
207
|
+
MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Manual),
|
|
208
|
+
).toBe(false);
|
|
209
|
+
expect(
|
|
210
|
+
MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Logs),
|
|
211
|
+
).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -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
|
}
|