@autter/otlp-ingester 0.1.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/Dockerfile +18 -0
- package/README.md +114 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +103 -0
- package/dist/clickhouse.d.ts +27 -0
- package/dist/clickhouse.js +261 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +51 -0
- package/dist/fingerprint.d.ts +19 -0
- package/dist/fingerprint.js +80 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +26 -0
- package/dist/migrations.d.ts +42 -0
- package/dist/migrations.js +88 -0
- package/dist/normalize-browser.d.ts +103 -0
- package/dist/normalize-browser.js +113 -0
- package/dist/normalize-otlp.d.ts +84 -0
- package/dist/normalize-otlp.js +258 -0
- package/dist/otlp-proto.d.ts +3 -0
- package/dist/otlp-proto.js +107 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +267 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +11 -0
- package/package.json +53 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic error grouping. Two occurrences share an issue when they
|
|
4
|
+
* share source + service + error type + normalised message + top stack
|
|
5
|
+
* frames + normalised route. Volatile fragments (ids, numbers, minified
|
|
6
|
+
* line/column offsets) are stripped so re-deploys and per-user values don't
|
|
7
|
+
* fragment issues. This algorithm must stay in sync with the Autter backend
|
|
8
|
+
* so browser-relay and OTLP occurrences group identically.
|
|
9
|
+
*/
|
|
10
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
11
|
+
const LONG_HEX_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
12
|
+
// No trailing \b: numbers glued to units ("4200ms", "3.5s", "512kb") must
|
|
13
|
+
// template too, or per-value messages fragment into separate fingerprints.
|
|
14
|
+
const NUMBER_RE = /\b\d+(\.\d+)?/g;
|
|
15
|
+
const QUOTED_RE = /(["'`])(?:\\.|(?!\1).)*\1/g;
|
|
16
|
+
export function normalizeMessage(message) {
|
|
17
|
+
return message
|
|
18
|
+
.slice(0, 500)
|
|
19
|
+
.replace(QUOTED_RE, "<str>")
|
|
20
|
+
.replace(UUID_RE, "<uuid>")
|
|
21
|
+
.replace(LONG_HEX_RE, "<hex>")
|
|
22
|
+
.replace(NUMBER_RE, "<n>")
|
|
23
|
+
.replace(/\s+/g, " ")
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
/** Replace id-like path segments so /orders/812 and /orders/44 group. */
|
|
27
|
+
export function normalizeRoute(route) {
|
|
28
|
+
if (!route)
|
|
29
|
+
return "";
|
|
30
|
+
const path = route.split("?")[0] ?? "";
|
|
31
|
+
return path
|
|
32
|
+
.split("/")
|
|
33
|
+
.map((segment) => {
|
|
34
|
+
if (!segment)
|
|
35
|
+
return segment;
|
|
36
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment))
|
|
37
|
+
return ":id";
|
|
38
|
+
if (/^\d+$/.test(segment))
|
|
39
|
+
return ":id";
|
|
40
|
+
if (/^[0-9a-f]{8,}$/i.test(segment))
|
|
41
|
+
return ":id";
|
|
42
|
+
return segment;
|
|
43
|
+
})
|
|
44
|
+
.join("/");
|
|
45
|
+
}
|
|
46
|
+
const FRAME_LOCATION_RE = /:\d+(:\d+)?\)?$/;
|
|
47
|
+
export function normalizeStackFrames(stack, topN = 5) {
|
|
48
|
+
if (!stack)
|
|
49
|
+
return [];
|
|
50
|
+
return stack
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => line.trim())
|
|
53
|
+
.filter((line) => /^at\s|@|^\s*File\s/.test(line) || /\.[jt]sx?/.test(line))
|
|
54
|
+
.slice(0, topN)
|
|
55
|
+
.map((line) => line
|
|
56
|
+
.replace(/\?[^:\s)]*/g, "")
|
|
57
|
+
.replace(FRAME_LOCATION_RE, "")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.trim());
|
|
60
|
+
}
|
|
61
|
+
export function fingerprintOccurrence(input) {
|
|
62
|
+
const parts = [
|
|
63
|
+
input.source,
|
|
64
|
+
input.service,
|
|
65
|
+
input.errorType,
|
|
66
|
+
normalizeMessage(input.message),
|
|
67
|
+
...normalizeStackFrames(input.stack),
|
|
68
|
+
normalizeRoute(input.route),
|
|
69
|
+
];
|
|
70
|
+
return createHash("sha256").update(parts.join(" ")).digest("hex").slice(0, 32);
|
|
71
|
+
}
|
|
72
|
+
export function deriveFields(input) {
|
|
73
|
+
const topFrames = normalizeStackFrames(input.stack);
|
|
74
|
+
return {
|
|
75
|
+
routeNormalized: normalizeRoute(input.route),
|
|
76
|
+
messageNormalized: normalizeMessage(input.message),
|
|
77
|
+
topFrames,
|
|
78
|
+
firstFrame: topFrames[0] ?? "",
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { createIngesterApp } from "./server.js";
|
|
3
|
+
const config = loadConfig();
|
|
4
|
+
const { app, store } = createIngesterApp(config);
|
|
5
|
+
const server = app.listen(config.port, () => {
|
|
6
|
+
console.log(`autter otlp-ingester listening on :${config.port} ` +
|
|
7
|
+
`(clickhouse: ${config.clickhouseUrl ? "configured" : "NOT configured"})`);
|
|
8
|
+
});
|
|
9
|
+
// Warm the schema at boot so the first ingest request doesn't pay for DDL.
|
|
10
|
+
if (store.configured) {
|
|
11
|
+
store.ensureSchema().catch((err) => {
|
|
12
|
+
console.error("clickhouse schema bootstrap failed (will retry on first ingest):", err?.message ?? err);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function shutdown(signal) {
|
|
16
|
+
console.log(`${signal} received, shutting down`);
|
|
17
|
+
server.close(() => {
|
|
18
|
+
void store.close().finally(() => process.exit(0));
|
|
19
|
+
});
|
|
20
|
+
setTimeout(() => process.exit(1), 10_000).unref();
|
|
21
|
+
}
|
|
22
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
23
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
24
|
+
export { createIngesterApp } from "./server.js";
|
|
25
|
+
export { loadConfig } from "./config.js";
|
|
26
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClickHouse schema migrations.
|
|
3
|
+
*
|
|
4
|
+
* The baseline schema in clickhouse.ts uses `CREATE TABLE IF NOT EXISTS`,
|
|
5
|
+
* which provisions FRESH databases but silently no-ops on existing ones —
|
|
6
|
+
* so schema changes to already-deployed databases go here. Migrations run
|
|
7
|
+
* automatically at ingester boot (and lazily before the first ingest),
|
|
8
|
+
* strictly in array order, and each applied id is recorded in
|
|
9
|
+
* `<db>.schema_migrations` so it runs exactly once per database.
|
|
10
|
+
*
|
|
11
|
+
* Rules for adding a migration:
|
|
12
|
+
* 1. Statements MUST be idempotent (`ADD COLUMN IF NOT EXISTS`,
|
|
13
|
+
* `DROP COLUMN IF EXISTS`, `MODIFY TTL`, …) — multiple ingester
|
|
14
|
+
* replicas may boot concurrently and race; idempotency makes the race
|
|
15
|
+
* harmless. The tracking table is bookkeeping, not a lock.
|
|
16
|
+
* 2. Additive only within a major version: new columns need a DEFAULT so
|
|
17
|
+
* old ingester replicas that are still running (rolling deploy) can
|
|
18
|
+
* keep inserting without naming them.
|
|
19
|
+
* 3. ALSO update the baseline in clickhouse.ts `schemaStatements()` so
|
|
20
|
+
* fresh databases come up with the final shape — a migration alone
|
|
21
|
+
* only fixes existing databases.
|
|
22
|
+
* 4. Never edit or reorder shipped migrations; append a corrective one.
|
|
23
|
+
*
|
|
24
|
+
* `{db}` is replaced with the configured database name at run time.
|
|
25
|
+
*
|
|
26
|
+
* Example (what adding a column looks like):
|
|
27
|
+
* {
|
|
28
|
+
* id: "0002-occurrences-sdk-version",
|
|
29
|
+
* statements: [
|
|
30
|
+
* `ALTER TABLE {db}.runtime_error_occurrences
|
|
31
|
+
* ADD COLUMN IF NOT EXISTS sdk_version String DEFAULT ''`,
|
|
32
|
+
* ],
|
|
33
|
+
* },
|
|
34
|
+
*/
|
|
35
|
+
export interface Migration {
|
|
36
|
+
/** Unique, ordered id: "<serial>-<slug>". Never reuse or reorder. */
|
|
37
|
+
id: string;
|
|
38
|
+
statements: string[];
|
|
39
|
+
}
|
|
40
|
+
export declare const MIGRATIONS: Migration[];
|
|
41
|
+
/** The tracking table itself — created by the runner before anything else. */
|
|
42
|
+
export declare function migrationsTableDDL(db: string): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClickHouse schema migrations.
|
|
3
|
+
*
|
|
4
|
+
* The baseline schema in clickhouse.ts uses `CREATE TABLE IF NOT EXISTS`,
|
|
5
|
+
* which provisions FRESH databases but silently no-ops on existing ones —
|
|
6
|
+
* so schema changes to already-deployed databases go here. Migrations run
|
|
7
|
+
* automatically at ingester boot (and lazily before the first ingest),
|
|
8
|
+
* strictly in array order, and each applied id is recorded in
|
|
9
|
+
* `<db>.schema_migrations` so it runs exactly once per database.
|
|
10
|
+
*
|
|
11
|
+
* Rules for adding a migration:
|
|
12
|
+
* 1. Statements MUST be idempotent (`ADD COLUMN IF NOT EXISTS`,
|
|
13
|
+
* `DROP COLUMN IF EXISTS`, `MODIFY TTL`, …) — multiple ingester
|
|
14
|
+
* replicas may boot concurrently and race; idempotency makes the race
|
|
15
|
+
* harmless. The tracking table is bookkeeping, not a lock.
|
|
16
|
+
* 2. Additive only within a major version: new columns need a DEFAULT so
|
|
17
|
+
* old ingester replicas that are still running (rolling deploy) can
|
|
18
|
+
* keep inserting without naming them.
|
|
19
|
+
* 3. ALSO update the baseline in clickhouse.ts `schemaStatements()` so
|
|
20
|
+
* fresh databases come up with the final shape — a migration alone
|
|
21
|
+
* only fixes existing databases.
|
|
22
|
+
* 4. Never edit or reorder shipped migrations; append a corrective one.
|
|
23
|
+
*
|
|
24
|
+
* `{db}` is replaced with the configured database name at run time.
|
|
25
|
+
*
|
|
26
|
+
* Example (what adding a column looks like):
|
|
27
|
+
* {
|
|
28
|
+
* id: "0002-occurrences-sdk-version",
|
|
29
|
+
* statements: [
|
|
30
|
+
* `ALTER TABLE {db}.runtime_error_occurrences
|
|
31
|
+
* ADD COLUMN IF NOT EXISTS sdk_version String DEFAULT ''`,
|
|
32
|
+
* ],
|
|
33
|
+
* },
|
|
34
|
+
*/
|
|
35
|
+
export const MIGRATIONS = [
|
|
36
|
+
// 0001 intentionally reserved as the baseline marker: databases created
|
|
37
|
+
// before the migration runner existed record it as applied without
|
|
38
|
+
// running anything (the baseline CREATEs already shaped them).
|
|
39
|
+
{ id: "0001-baseline", statements: [] },
|
|
40
|
+
// Aggregation-ready occurrence shape: severity (warnings/info share the
|
|
41
|
+
// table with errors), pre-normalised route/message, extracted stack
|
|
42
|
+
// frames, and the request method — so later aggregations GROUP BY
|
|
43
|
+
// stored columns instead of re-parsing stacks/routes in SQL.
|
|
44
|
+
{
|
|
45
|
+
id: "0002-occurrences-aggregation-columns",
|
|
46
|
+
statements: [
|
|
47
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
48
|
+
ADD COLUMN IF NOT EXISTS severity LowCardinality(String) DEFAULT 'error' AFTER source`,
|
|
49
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
50
|
+
ADD COLUMN IF NOT EXISTS message_normalized String DEFAULT '' AFTER message`,
|
|
51
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
52
|
+
ADD COLUMN IF NOT EXISTS top_frames Array(String) DEFAULT [] AFTER stack`,
|
|
53
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
54
|
+
ADD COLUMN IF NOT EXISTS first_frame String DEFAULT '' AFTER top_frames`,
|
|
55
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
56
|
+
ADD COLUMN IF NOT EXISTS route_normalized String DEFAULT '' AFTER route`,
|
|
57
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
58
|
+
ADD COLUMN IF NOT EXISTS method LowCardinality(String) DEFAULT '' AFTER route_normalized`,
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
// ZSTD compression on the fat text columns — stack traces and JSON
|
|
62
|
+
// attributes compress several times better under ZSTD than the LZ4
|
|
63
|
+
// default, and these columns dominate on-disk size. MODIFY COLUMN with
|
|
64
|
+
// a codec is idempotent and cheap: new parts use it immediately, old
|
|
65
|
+
// parts recompress on background merges.
|
|
66
|
+
{
|
|
67
|
+
id: "0003-compress-fat-columns",
|
|
68
|
+
statements: [
|
|
69
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
70
|
+
MODIFY COLUMN stack String DEFAULT '' CODEC(ZSTD(3))`,
|
|
71
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
72
|
+
MODIFY COLUMN message String CODEC(ZSTD(1))`,
|
|
73
|
+
`ALTER TABLE {db}.runtime_error_occurrences
|
|
74
|
+
MODIFY COLUMN attributes String DEFAULT '{}' CODEC(ZSTD(1))`,
|
|
75
|
+
`ALTER TABLE {db}.runtime_spans
|
|
76
|
+
MODIFY COLUMN attributes String DEFAULT '{}' CODEC(ZSTD(1))`,
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
/** The tracking table itself — created by the runner before anything else. */
|
|
81
|
+
export function migrationsTableDDL(db) {
|
|
82
|
+
return `CREATE TABLE IF NOT EXISTS ${db}.schema_migrations (
|
|
83
|
+
id String,
|
|
84
|
+
applied_at DateTime('UTC') DEFAULT now()
|
|
85
|
+
)
|
|
86
|
+
ENGINE = ReplacingMergeTree
|
|
87
|
+
ORDER BY id`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type RuntimeMetricPoint, type RuntimeOccurrenceInput } from "./types.js";
|
|
3
|
+
export declare const browserPayloadSchema: z.ZodObject<{
|
|
4
|
+
version: z.ZodLiteral<1>;
|
|
5
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
6
|
+
service: z.ZodString;
|
|
7
|
+
environment: z.ZodString;
|
|
8
|
+
release: z.ZodOptional<z.ZodString>;
|
|
9
|
+
events: z.ZodArray<z.ZodObject<{
|
|
10
|
+
id: z.ZodOptional<z.ZodString>;
|
|
11
|
+
type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event"]>;
|
|
12
|
+
timestamp: z.ZodString;
|
|
13
|
+
/** exception/message events: signal level. Defaults per type. */
|
|
14
|
+
severity: z.ZodOptional<z.ZodEnum<["fatal", "error", "warning", "info"]>>;
|
|
15
|
+
message: z.ZodDefault<z.ZodString>;
|
|
16
|
+
/** track_event only: the event name (counted, never free-form PII). */
|
|
17
|
+
name: z.ZodOptional<z.ZodString>;
|
|
18
|
+
stack: z.ZodOptional<z.ZodString>;
|
|
19
|
+
errorType: z.ZodOptional<z.ZodString>;
|
|
20
|
+
filename: z.ZodOptional<z.ZodString>;
|
|
21
|
+
line: z.ZodOptional<z.ZodNumber>;
|
|
22
|
+
column: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
/** Path only; query strings are stripped defensively anyway. */
|
|
24
|
+
route: z.ZodOptional<z.ZodString>;
|
|
25
|
+
context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
26
|
+
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
28
|
+
message: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
id?: string | undefined;
|
|
31
|
+
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
32
|
+
name?: string | undefined;
|
|
33
|
+
stack?: string | undefined;
|
|
34
|
+
errorType?: string | undefined;
|
|
35
|
+
filename?: string | undefined;
|
|
36
|
+
line?: number | undefined;
|
|
37
|
+
column?: number | undefined;
|
|
38
|
+
route?: string | undefined;
|
|
39
|
+
context?: Record<string, unknown> | undefined;
|
|
40
|
+
}, {
|
|
41
|
+
type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
42
|
+
timestamp: string;
|
|
43
|
+
id?: string | undefined;
|
|
44
|
+
message?: string | undefined;
|
|
45
|
+
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
46
|
+
name?: string | undefined;
|
|
47
|
+
stack?: string | undefined;
|
|
48
|
+
errorType?: string | undefined;
|
|
49
|
+
filename?: string | undefined;
|
|
50
|
+
line?: number | undefined;
|
|
51
|
+
column?: number | undefined;
|
|
52
|
+
route?: string | undefined;
|
|
53
|
+
context?: Record<string, unknown> | undefined;
|
|
54
|
+
}>, "many">;
|
|
55
|
+
}, "strip", z.ZodTypeAny, {
|
|
56
|
+
version: 1;
|
|
57
|
+
service: string;
|
|
58
|
+
environment: string;
|
|
59
|
+
events: {
|
|
60
|
+
type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
61
|
+
message: string;
|
|
62
|
+
timestamp: string;
|
|
63
|
+
id?: string | undefined;
|
|
64
|
+
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
65
|
+
name?: string | undefined;
|
|
66
|
+
stack?: string | undefined;
|
|
67
|
+
errorType?: string | undefined;
|
|
68
|
+
filename?: string | undefined;
|
|
69
|
+
line?: number | undefined;
|
|
70
|
+
column?: number | undefined;
|
|
71
|
+
route?: string | undefined;
|
|
72
|
+
context?: Record<string, unknown> | undefined;
|
|
73
|
+
}[];
|
|
74
|
+
sessionId?: string | undefined;
|
|
75
|
+
release?: string | undefined;
|
|
76
|
+
}, {
|
|
77
|
+
version: 1;
|
|
78
|
+
service: string;
|
|
79
|
+
environment: string;
|
|
80
|
+
events: {
|
|
81
|
+
type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
82
|
+
timestamp: string;
|
|
83
|
+
id?: string | undefined;
|
|
84
|
+
message?: string | undefined;
|
|
85
|
+
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
86
|
+
name?: string | undefined;
|
|
87
|
+
stack?: string | undefined;
|
|
88
|
+
errorType?: string | undefined;
|
|
89
|
+
filename?: string | undefined;
|
|
90
|
+
line?: number | undefined;
|
|
91
|
+
column?: number | undefined;
|
|
92
|
+
route?: string | undefined;
|
|
93
|
+
context?: Record<string, unknown> | undefined;
|
|
94
|
+
}[];
|
|
95
|
+
sessionId?: string | undefined;
|
|
96
|
+
release?: string | undefined;
|
|
97
|
+
}>;
|
|
98
|
+
export type BrowserPayload = z.infer<typeof browserPayloadSchema>;
|
|
99
|
+
export interface NormalizedBrowser {
|
|
100
|
+
occurrences: RuntimeOccurrenceInput[];
|
|
101
|
+
metricPoints: RuntimeMetricPoint[];
|
|
102
|
+
}
|
|
103
|
+
export declare function normalizeBrowserPayload(payload: BrowserPayload): NormalizedBrowser;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { asSeverity, } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The compact browser payload (`version: 1`) emitted by
|
|
5
|
+
* `@autter/runtime-browser` and forwarded by the customer's same-origin
|
|
6
|
+
* relay. The schema is a privacy gate as much as a validator: anything not
|
|
7
|
+
* whitelisted here never reaches storage.
|
|
8
|
+
*/
|
|
9
|
+
const browserEventSchema = z.object({
|
|
10
|
+
id: z.string().max(64).optional(),
|
|
11
|
+
type: z.enum([
|
|
12
|
+
"exception",
|
|
13
|
+
"unhandled_rejection",
|
|
14
|
+
"message",
|
|
15
|
+
"session_start",
|
|
16
|
+
"track_event",
|
|
17
|
+
]),
|
|
18
|
+
timestamp: z.string().datetime(),
|
|
19
|
+
/** exception/message events: signal level. Defaults per type. */
|
|
20
|
+
severity: z.enum(["fatal", "error", "warning", "info"]).optional(),
|
|
21
|
+
message: z.string().max(4000).default(""),
|
|
22
|
+
/** track_event only: the event name (counted, never free-form PII). */
|
|
23
|
+
name: z.string().max(200).optional(),
|
|
24
|
+
stack: z.string().max(32000).optional(),
|
|
25
|
+
errorType: z.string().max(200).optional(),
|
|
26
|
+
filename: z.string().max(1000).optional(),
|
|
27
|
+
line: z.number().int().nonnegative().optional(),
|
|
28
|
+
column: z.number().int().nonnegative().optional(),
|
|
29
|
+
/** Path only; query strings are stripped defensively anyway. */
|
|
30
|
+
route: z.string().max(1000).optional(),
|
|
31
|
+
context: z.record(z.unknown()).optional(),
|
|
32
|
+
});
|
|
33
|
+
export const browserPayloadSchema = z.object({
|
|
34
|
+
version: z.literal(1),
|
|
35
|
+
sessionId: z.string().max(100).optional(),
|
|
36
|
+
service: z.string().min(1).max(200),
|
|
37
|
+
environment: z.string().min(1).max(100),
|
|
38
|
+
release: z.string().max(200).optional(),
|
|
39
|
+
events: z.array(browserEventSchema).max(50),
|
|
40
|
+
});
|
|
41
|
+
const TYPE_TO_ERROR_TYPE = {
|
|
42
|
+
exception: "Error",
|
|
43
|
+
unhandled_rejection: "UnhandledRejection",
|
|
44
|
+
message: "Message",
|
|
45
|
+
};
|
|
46
|
+
/** Default severity per event type when the SDK doesn't say. */
|
|
47
|
+
const TYPE_TO_SEVERITY = {
|
|
48
|
+
exception: "error",
|
|
49
|
+
unhandled_rejection: "error",
|
|
50
|
+
message: "warning",
|
|
51
|
+
};
|
|
52
|
+
export function normalizeBrowserPayload(payload) {
|
|
53
|
+
const occurrences = [];
|
|
54
|
+
const rollups = new Map();
|
|
55
|
+
function bumpRollup(route, occurredAt, field) {
|
|
56
|
+
const bucketAt = new Date(Math.floor(occurredAt.getTime() / 60_000) * 60_000);
|
|
57
|
+
const key = `${route} ${bucketAt.getTime()}`;
|
|
58
|
+
const existing = rollups.get(key);
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing[field] += 1;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
rollups.set(key, {
|
|
64
|
+
service: payload.service,
|
|
65
|
+
environment: payload.environment,
|
|
66
|
+
release: payload.release ?? null,
|
|
67
|
+
route,
|
|
68
|
+
bucketAt,
|
|
69
|
+
requestCount: field === "requestCount" ? 1 : 0,
|
|
70
|
+
errorCount: 0,
|
|
71
|
+
durationSumMs: 0,
|
|
72
|
+
sessionCount: field === "sessionCount" ? 1 : 0,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
for (const event of payload.events) {
|
|
76
|
+
const occurredAt = new Date(event.timestamp);
|
|
77
|
+
if (event.type === "session_start") {
|
|
78
|
+
bumpRollup("", occurredAt, "sessionCount");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Coarse usage counters: track_event("checkout_opened") becomes a
|
|
82
|
+
// request_count increment on the synthetic route "event:checkout_opened".
|
|
83
|
+
if (event.type === "track_event") {
|
|
84
|
+
const name = (event.name ?? event.message ?? "").slice(0, 200);
|
|
85
|
+
if (name)
|
|
86
|
+
bumpRollup(`event:${name}`, occurredAt, "requestCount");
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
occurrences.push({
|
|
90
|
+
source: "browser",
|
|
91
|
+
severity: asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error"),
|
|
92
|
+
service: payload.service,
|
|
93
|
+
environment: payload.environment,
|
|
94
|
+
release: payload.release ?? null,
|
|
95
|
+
errorType: event.errorType ?? TYPE_TO_ERROR_TYPE[event.type] ?? "Error",
|
|
96
|
+
message: event.message || "Unknown error",
|
|
97
|
+
stack: event.stack ?? null,
|
|
98
|
+
route: event.route ? (event.route.split("?")[0] ?? null) : null,
|
|
99
|
+
method: null,
|
|
100
|
+
statusCode: null,
|
|
101
|
+
traceId: null,
|
|
102
|
+
sessionId: payload.sessionId ?? null,
|
|
103
|
+
attributes: {
|
|
104
|
+
...(event.filename ? { filename: event.filename.split("?")[0] } : {}),
|
|
105
|
+
...(event.line !== undefined ? { line: event.line } : {}),
|
|
106
|
+
...(event.column !== undefined ? { column: event.column } : {}),
|
|
107
|
+
...(event.context ? { context: event.context } : {}),
|
|
108
|
+
},
|
|
109
|
+
occurredAt,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return { occurrences, metricPoints: [...rollups.values()] };
|
|
113
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type RuntimeMetricPoint, type RuntimeOccurrenceInput, type RuntimeSpanRow } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* OTLP/HTTP JSON → runtime signal. Structural types cover only the fields
|
|
4
|
+
* we read (the full OTLP schema is large and versioned; unknown fields pass
|
|
5
|
+
* through untouched). Protobuf decode is Milestone 1 — see docs/PLAN.md.
|
|
6
|
+
*
|
|
7
|
+
* Note: OTLP/JSON encodes trace/span ids as hex strings and enum fields as
|
|
8
|
+
* either numbers or `SPAN_KIND_*` / `STATUS_CODE_*` strings depending on the
|
|
9
|
+
* serialiser — both are accepted.
|
|
10
|
+
*/
|
|
11
|
+
interface OtlpKeyValue {
|
|
12
|
+
key?: string;
|
|
13
|
+
value?: {
|
|
14
|
+
stringValue?: string;
|
|
15
|
+
intValue?: string | number;
|
|
16
|
+
doubleValue?: number;
|
|
17
|
+
boolValue?: boolean;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
interface OtlpEvent {
|
|
21
|
+
name?: string;
|
|
22
|
+
timeUnixNano?: string | number;
|
|
23
|
+
attributes?: OtlpKeyValue[];
|
|
24
|
+
}
|
|
25
|
+
interface OtlpSpan {
|
|
26
|
+
traceId?: string;
|
|
27
|
+
spanId?: string;
|
|
28
|
+
parentSpanId?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
kind?: number | string;
|
|
31
|
+
startTimeUnixNano?: string | number;
|
|
32
|
+
endTimeUnixNano?: string | number;
|
|
33
|
+
attributes?: OtlpKeyValue[];
|
|
34
|
+
status?: {
|
|
35
|
+
code?: number | string;
|
|
36
|
+
message?: string;
|
|
37
|
+
};
|
|
38
|
+
events?: OtlpEvent[];
|
|
39
|
+
}
|
|
40
|
+
interface OtlpResource {
|
|
41
|
+
attributes?: OtlpKeyValue[];
|
|
42
|
+
}
|
|
43
|
+
export interface OtlpTraceRequest {
|
|
44
|
+
resourceSpans?: Array<{
|
|
45
|
+
resource?: OtlpResource;
|
|
46
|
+
scopeSpans?: Array<{
|
|
47
|
+
spans?: OtlpSpan[];
|
|
48
|
+
}>;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
interface OtlpDataPoint {
|
|
52
|
+
attributes?: OtlpKeyValue[];
|
|
53
|
+
timeUnixNano?: string | number;
|
|
54
|
+
count?: string | number;
|
|
55
|
+
sum?: number;
|
|
56
|
+
asInt?: string | number;
|
|
57
|
+
asDouble?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface OtlpMetricsRequest {
|
|
60
|
+
resourceMetrics?: Array<{
|
|
61
|
+
resource?: OtlpResource;
|
|
62
|
+
scopeMetrics?: Array<{
|
|
63
|
+
metrics?: Array<{
|
|
64
|
+
name?: string;
|
|
65
|
+
unit?: string;
|
|
66
|
+
histogram?: {
|
|
67
|
+
dataPoints?: OtlpDataPoint[];
|
|
68
|
+
};
|
|
69
|
+
sum?: {
|
|
70
|
+
dataPoints?: OtlpDataPoint[];
|
|
71
|
+
};
|
|
72
|
+
}>;
|
|
73
|
+
}>;
|
|
74
|
+
}>;
|
|
75
|
+
}
|
|
76
|
+
export interface NormalizedTraces {
|
|
77
|
+
occurrences: RuntimeOccurrenceInput[];
|
|
78
|
+
spans: RuntimeSpanRow[];
|
|
79
|
+
metricPoints: RuntimeMetricPoint[];
|
|
80
|
+
spanCount: number;
|
|
81
|
+
}
|
|
82
|
+
export declare function normalizeTraces(request: OtlpTraceRequest): NormalizedTraces;
|
|
83
|
+
export declare function normalizeMetrics(request: OtlpMetricsRequest): RuntimeMetricPoint[];
|
|
84
|
+
export {};
|