@autter/otlp-ingester 1.7.0 → 1.8.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/README.md +17 -3
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +19 -7
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +38 -1
- package/dist/compat-manifest.json +21 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.js +49 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/ingest-drops.d.ts +308 -0
- package/dist/ingest-drops.js +448 -0
- package/dist/log-promotion.d.ts +4 -3
- package/dist/log-promotion.js +5 -5
- package/dist/logs.d.ts +16 -1
- package/dist/logs.js +17 -3
- package/dist/memory.d.ts +9 -1
- package/dist/memory.js +28 -7
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.js +29 -1
- package/dist/normalize-browser.d.ts +39 -11
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.js +165 -45
- package/dist/otlp-proto.d.ts +14 -0
- package/dist/otlp-proto.js +25 -0
- package/dist/profiles.d.ts +4 -0
- package/dist/profiles.js +6 -2
- package/dist/request-spans.d.ts +45 -0
- package/dist/request-spans.js +91 -0
- package/dist/sdk-versions.d.ts +39 -2
- package/dist/sdk-versions.js +49 -8
- package/dist/server.d.ts +3 -0
- package/dist/server.js +304 -98
- package/dist/sink.d.ts +14 -0
- package/dist/sink.js +15 -0
- package/dist/stack-frames.d.ts +5 -2
- package/dist/stack-frames.js +55 -9
- package/dist/symbolicate.d.ts +13 -0
- package/dist/symbolicate.js +22 -0
- package/package.json +1 -1
package/dist/migrations.js
CHANGED
|
@@ -37,7 +37,8 @@ import { profileTableDDL } from "./profiles.js";
|
|
|
37
37
|
import { SOURCE_MAP_DEBUG_COLUMNS, SOURCE_MAP_DEBUG_ID_INDEX, sourceMapTableDDL, } from "./source-maps.js";
|
|
38
38
|
import { LOG_REQUEST_COLUMNS, LOG_REQUEST_ID_INDEX, logTableDDL, requestRollupTableDDL, requestRollupViewDDL, } from "./logs.js";
|
|
39
39
|
import { memoryTableDDL, platformEventTableDDL } from "./memory.js";
|
|
40
|
-
import { ingesterInfoTableDDL, sdkVersionTableDDL } from "./sdk-versions.js";
|
|
40
|
+
import { ingesterInfoTableDDL, SDK_VERSION_VIA_COLUMN, sdkVersionTableDDL } from "./sdk-versions.js";
|
|
41
|
+
import { ingestDropsTableDDL } from "./ingest-drops.js";
|
|
41
42
|
/** Migration 0013 columns — shared with the baseline DDL in clickhouse.ts. */
|
|
42
43
|
export const OCCURRENCE_CODE_COLUMNS = [
|
|
43
44
|
"error_code String DEFAULT ''",
|
|
@@ -62,6 +63,17 @@ export const OCCURRENCE_GROUPING_COLUMNS = [
|
|
|
62
63
|
"frames String DEFAULT '' CODEC(ZSTD(3))",
|
|
63
64
|
"fingerprints Array(String) DEFAULT []",
|
|
64
65
|
];
|
|
66
|
+
/**
|
|
67
|
+
* Migration 0020 columns — shared with the baseline DDL in clickhouse.ts.
|
|
68
|
+
* In-app browser JS frames a source map resolved / left unresolved at
|
|
69
|
+
* ingest (symbolicate.ts inAppFrameResolution); 0 and 0 for server
|
|
70
|
+
* occurrences, browser ones with no in-app JS frame, and rows written
|
|
71
|
+
* before 0020.
|
|
72
|
+
*/
|
|
73
|
+
export const OCCURRENCE_FRAME_RESOLUTION_COLUMNS = [
|
|
74
|
+
"in_app_frames_resolved UInt16 DEFAULT 0",
|
|
75
|
+
"in_app_frames_unresolved UInt16 DEFAULT 0",
|
|
76
|
+
];
|
|
65
77
|
export const MIGRATIONS = [
|
|
66
78
|
// 0001 intentionally reserved as the baseline marker: databases created
|
|
67
79
|
// before the migration runner existed record it as applied without
|
|
@@ -202,6 +214,22 @@ export const MIGRATIONS = [
|
|
|
202
214
|
`ALTER TABLE {db}.runtime_source_maps ADD ${SOURCE_MAP_DEBUG_ID_INDEX.replace("INDEX ", "INDEX IF NOT EXISTS ")}`,
|
|
203
215
|
],
|
|
204
216
|
},
|
|
217
|
+
// Rejections and drops per tenant, minute, route and reason (EI-27):
|
|
218
|
+
// what the ingester refused or did not store, so a repository's missing
|
|
219
|
+
// telemetry has a reason (ingest-drops.ts).
|
|
220
|
+
{ id: "0018-runtime-ingest-drops", statements: [ingestDropsTableDDL("{db}")] },
|
|
221
|
+
// How each service's telemetry arrived: an OpenTelemetry Collector, an
|
|
222
|
+
// SDK exporter directly, or the browser relay (sdk-versions.ts).
|
|
223
|
+
{
|
|
224
|
+
id: "0019-sdk-versions-via",
|
|
225
|
+
statements: [`ALTER TABLE {db}.runtime_sdk_versions ADD COLUMN IF NOT EXISTS ${SDK_VERSION_VIA_COLUMN}`],
|
|
226
|
+
},
|
|
227
|
+
// Resolved and unresolved in-app browser frames per occurrence, after
|
|
228
|
+
// symbolication — the share the EI-14 source map rule reads.
|
|
229
|
+
{
|
|
230
|
+
id: "0020-occurrence-frame-resolution",
|
|
231
|
+
statements: OCCURRENCE_FRAME_RESOLUTION_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
|
|
232
|
+
},
|
|
205
233
|
];
|
|
206
234
|
/** The tracking table itself — created by the runner before anything else. */
|
|
207
235
|
export function migrationsTableDDL(db) {
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type RuntimeMetricPoint, type RuntimeOccurrenceInput } from "./types.js";
|
|
3
|
+
/** Events past this in one payload are dropped and counted (events_over_limit). */
|
|
4
|
+
export declare const MAX_BROWSER_EVENTS = 50;
|
|
5
|
+
/** A whole payload, every event valid: what normalizeBrowserPayload takes. */
|
|
3
6
|
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
|
-
/** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
|
|
10
|
-
sdk: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
11
7
|
events: z.ZodArray<z.ZodObject<{
|
|
12
8
|
id: z.ZodOptional<z.ZodString>;
|
|
13
9
|
type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event", "outcome", "request_failure", "csp_violation", "timing"]>;
|
|
@@ -64,9 +60,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
64
60
|
debugIds?: Record<string, unknown> | undefined;
|
|
65
61
|
durationMs?: number | undefined;
|
|
66
62
|
filename?: string | undefined;
|
|
63
|
+
name?: string | undefined;
|
|
67
64
|
column?: number | undefined;
|
|
68
65
|
line?: number | undefined;
|
|
69
|
-
name?: string | undefined;
|
|
70
66
|
id?: string | undefined;
|
|
71
67
|
context?: Record<string, unknown> | undefined;
|
|
72
68
|
fingerprint?: string | string[] | undefined;
|
|
@@ -83,13 +79,20 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
83
79
|
debugIds?: unknown;
|
|
84
80
|
durationMs?: number | undefined;
|
|
85
81
|
filename?: string | undefined;
|
|
82
|
+
name?: string | undefined;
|
|
86
83
|
column?: number | undefined;
|
|
87
84
|
line?: number | undefined;
|
|
88
|
-
name?: string | undefined;
|
|
89
85
|
id?: string | undefined;
|
|
90
86
|
context?: Record<string, unknown> | undefined;
|
|
91
87
|
fingerprint?: unknown;
|
|
92
88
|
}>, "many">;
|
|
89
|
+
version: z.ZodLiteral<1>;
|
|
90
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
91
|
+
service: z.ZodString;
|
|
92
|
+
environment: z.ZodString;
|
|
93
|
+
release: z.ZodOptional<z.ZodString>;
|
|
94
|
+
/** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
|
|
95
|
+
sdk: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
93
96
|
}, "strip", z.ZodTypeAny, {
|
|
94
97
|
version: 1;
|
|
95
98
|
service: string;
|
|
@@ -107,9 +110,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
107
110
|
debugIds?: Record<string, unknown> | undefined;
|
|
108
111
|
durationMs?: number | undefined;
|
|
109
112
|
filename?: string | undefined;
|
|
113
|
+
name?: string | undefined;
|
|
110
114
|
column?: number | undefined;
|
|
111
115
|
line?: number | undefined;
|
|
112
|
-
name?: string | undefined;
|
|
113
116
|
id?: string | undefined;
|
|
114
117
|
context?: Record<string, unknown> | undefined;
|
|
115
118
|
fingerprint?: string | string[] | undefined;
|
|
@@ -134,9 +137,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
134
137
|
debugIds?: unknown;
|
|
135
138
|
durationMs?: number | undefined;
|
|
136
139
|
filename?: string | undefined;
|
|
140
|
+
name?: string | undefined;
|
|
137
141
|
column?: number | undefined;
|
|
138
142
|
line?: number | undefined;
|
|
139
|
-
name?: string | undefined;
|
|
140
143
|
id?: string | undefined;
|
|
141
144
|
context?: Record<string, unknown> | undefined;
|
|
142
145
|
fingerprint?: unknown;
|
|
@@ -146,6 +149,31 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
146
149
|
sdk?: unknown;
|
|
147
150
|
}>;
|
|
148
151
|
export type BrowserPayload = z.infer<typeof browserPayloadSchema>;
|
|
152
|
+
export interface ParsedBrowserPayload {
|
|
153
|
+
/** The envelope with its valid events (at most MAX_BROWSER_EVENTS). */
|
|
154
|
+
payload: BrowserPayload;
|
|
155
|
+
/** Events dropped because they failed validation (invalid_event). */
|
|
156
|
+
invalidEvents: number;
|
|
157
|
+
/** Events past MAX_BROWSER_EVENTS, not looked at (events_over_limit). */
|
|
158
|
+
overLimitEvents: number;
|
|
159
|
+
/** The first validation issues of the dropped events; `path` starts with ["events", index]. */
|
|
160
|
+
issues: z.ZodIssue[];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Validate a browser payload event by event (1.8.0). The envelope (version,
|
|
164
|
+
* service, environment) must be valid or the payload is refused; an event
|
|
165
|
+
* that fails validation — a type this ingester doesn't know, a bad
|
|
166
|
+
* timestamp, an oversized field — is dropped on its own and counted, so it
|
|
167
|
+
* no longer costs the errors sent in the same beacon. Events past
|
|
168
|
+
* MAX_BROWSER_EVENTS are dropped and counted unread.
|
|
169
|
+
*/
|
|
170
|
+
export declare function parseBrowserPayload(body: unknown): {
|
|
171
|
+
success: true;
|
|
172
|
+
data: ParsedBrowserPayload;
|
|
173
|
+
} | {
|
|
174
|
+
success: false;
|
|
175
|
+
issues: z.ZodIssue[];
|
|
176
|
+
};
|
|
149
177
|
export interface NormalizedBrowser {
|
|
150
178
|
occurrences: RuntimeOccurrenceInput[];
|
|
151
179
|
metricPoints: RuntimeMetricPoint[];
|
|
@@ -67,7 +67,9 @@ const browserEventSchema = z.object({
|
|
|
67
67
|
*/
|
|
68
68
|
traceId: z.string().regex(/^(?!0{32})[0-9a-f]{32}$/).optional().catch(undefined),
|
|
69
69
|
});
|
|
70
|
-
|
|
70
|
+
/** Events past this in one payload are dropped and counted (events_over_limit). */
|
|
71
|
+
export const MAX_BROWSER_EVENTS = 50;
|
|
72
|
+
const browserEnvelope = {
|
|
71
73
|
version: z.literal(1),
|
|
72
74
|
sessionId: z.string().max(100).optional(),
|
|
73
75
|
service: z.string().min(1).max(200),
|
|
@@ -75,8 +77,53 @@ export const browserPayloadSchema = z.object({
|
|
|
75
77
|
release: z.string().max(200).optional(),
|
|
76
78
|
/** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
|
|
77
79
|
sdk: z.string().regex(/^\d{1,6}\.\d{1,6}\.\d{1,6}[0-9A-Za-z.+-]{0,20}$/).optional().catch(undefined),
|
|
78
|
-
|
|
80
|
+
};
|
|
81
|
+
/** A whole payload, every event valid: what normalizeBrowserPayload takes. */
|
|
82
|
+
export const browserPayloadSchema = z.object({
|
|
83
|
+
...browserEnvelope,
|
|
84
|
+
events: z.array(browserEventSchema).max(MAX_BROWSER_EVENTS),
|
|
79
85
|
});
|
|
86
|
+
const browserEnvelopeSchema = z.object({ ...browserEnvelope, events: z.array(z.unknown()) });
|
|
87
|
+
/**
|
|
88
|
+
* Validate a browser payload event by event (1.8.0). The envelope (version,
|
|
89
|
+
* service, environment) must be valid or the payload is refused; an event
|
|
90
|
+
* that fails validation — a type this ingester doesn't know, a bad
|
|
91
|
+
* timestamp, an oversized field — is dropped on its own and counted, so it
|
|
92
|
+
* no longer costs the errors sent in the same beacon. Events past
|
|
93
|
+
* MAX_BROWSER_EVENTS are dropped and counted unread.
|
|
94
|
+
*/
|
|
95
|
+
export function parseBrowserPayload(body) {
|
|
96
|
+
const envelope = browserEnvelopeSchema.safeParse(body);
|
|
97
|
+
if (!envelope.success)
|
|
98
|
+
return { success: false, issues: envelope.error.issues.slice(0, 5) };
|
|
99
|
+
const raw = envelope.data.events;
|
|
100
|
+
const considered = raw.slice(0, MAX_BROWSER_EVENTS);
|
|
101
|
+
const events = [];
|
|
102
|
+
const issues = [];
|
|
103
|
+
let invalidEvents = 0;
|
|
104
|
+
considered.forEach((candidate, index) => {
|
|
105
|
+
const parsed = browserEventSchema.safeParse(candidate);
|
|
106
|
+
if (parsed.success) {
|
|
107
|
+
events.push(parsed.data);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
invalidEvents += 1;
|
|
111
|
+
for (const issue of parsed.error.issues) {
|
|
112
|
+
if (issues.length >= 5)
|
|
113
|
+
break;
|
|
114
|
+
issues.push({ ...issue, path: ["events", index, ...issue.path] });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
success: true,
|
|
119
|
+
data: {
|
|
120
|
+
payload: { ...envelope.data, events },
|
|
121
|
+
invalidEvents,
|
|
122
|
+
overLimitEvents: raw.length - considered.length,
|
|
123
|
+
issues,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
80
127
|
const TYPE_TO_ERROR_TYPE = {
|
|
81
128
|
exception: "Error",
|
|
82
129
|
unhandled_rejection: "UnhandledRejection",
|
|
@@ -154,14 +201,13 @@ export function normalizeBrowserPayload(payload) {
|
|
|
154
201
|
continue;
|
|
155
202
|
}
|
|
156
203
|
const severity = asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error");
|
|
157
|
-
//
|
|
158
|
-
// only
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
});
|
|
204
|
+
// A browser error belongs to no server request: it counts in
|
|
205
|
+
// `error_count` only (fatal/error severity), never `request_count`,
|
|
206
|
+
// so a page that only reports errors still shows them without
|
|
207
|
+
// inventing requests. Warnings and info messages count nothing.
|
|
208
|
+
if (severity === "fatal" || severity === "error") {
|
|
209
|
+
bumpRollup(event.route ? (event.route.split("?")[0] ?? "") : "", occurredAt, { errorCount: 1 });
|
|
210
|
+
}
|
|
165
211
|
occurrences.push({
|
|
166
212
|
source: "browser",
|
|
167
213
|
severity,
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -6,6 +6,7 @@ import { normalizeRoute } from "./fingerprint.js";
|
|
|
6
6
|
import { extractLlmCall } from "./llm.js";
|
|
7
7
|
import { BREADCRUMBS_ATTRIBUTE, breadcrumbsField } from "./breadcrumbs.js";
|
|
8
8
|
import { dedupeTraceSignals, hasAutterKey, hasNextContext } from "./trace-dedupe.js";
|
|
9
|
+
import { requestMembership } from "./request-spans.js";
|
|
9
10
|
import { asSeverity, } from "./types.js";
|
|
10
11
|
const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
|
|
11
12
|
function attrMap(attributes) {
|
|
@@ -57,13 +58,18 @@ function spanKind(kind) {
|
|
|
57
58
|
}
|
|
58
59
|
function resourceInfo(resource) {
|
|
59
60
|
const attrs = attrMap(resource?.attributes);
|
|
61
|
+
const service = attrs.get("service.name") ?? "unknown";
|
|
62
|
+
const environment = attrs.get("deployment.environment.name") ??
|
|
63
|
+
attrs.get("deployment.environment") ??
|
|
64
|
+
"production";
|
|
65
|
+
const release = attrs.get("service.version") ?? null;
|
|
60
66
|
return {
|
|
61
|
-
service
|
|
62
|
-
environment
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
release: attrs.get("service.version") ?? null,
|
|
67
|
+
service,
|
|
68
|
+
environment,
|
|
69
|
+
release,
|
|
70
|
+
metricsCountRoutedRequests: attrs.get("autter.metrics_wired") === "true" && attrs.get("autter.metrics_routes") === "true",
|
|
66
71
|
metricsWired: attrs.get("autter.metrics_wired") === "true",
|
|
72
|
+
process: [service, environment, release ?? "", attrs.get("service.instance.id") ?? ""].join("\u0000"),
|
|
67
73
|
};
|
|
68
74
|
}
|
|
69
75
|
function routeOf(attrs) {
|
|
@@ -73,6 +79,13 @@ function routeOf(attrs) {
|
|
|
73
79
|
null;
|
|
74
80
|
return route ? (route.split("?")[0] ?? null) : null;
|
|
75
81
|
}
|
|
82
|
+
/** How good a request name `routeOf` found: 2 for a route template
|
|
83
|
+
* (`http.route`), 1 for a raw path (`url.path` / `http.target`), 0 for none. */
|
|
84
|
+
function routeQualityOf(attrs, route) {
|
|
85
|
+
if (!route)
|
|
86
|
+
return 0;
|
|
87
|
+
return attrs.has("http.route") ? 2 : 1;
|
|
88
|
+
}
|
|
76
89
|
function statusCodeOf(attrs) {
|
|
77
90
|
const raw = attrs.get("http.response.status_code") ?? attrs.get("http.status_code");
|
|
78
91
|
if (!raw)
|
|
@@ -102,10 +115,12 @@ function severityOf(eventAttrs, spanAttrs) {
|
|
|
102
115
|
const MAX_SPANS_PER_REQUEST = 5000;
|
|
103
116
|
export function normalizeTraces(request) {
|
|
104
117
|
const signals = [];
|
|
105
|
-
// Per signal:
|
|
106
|
-
//
|
|
118
|
+
// Per signal: the span it was recorded on (its index in `spans`), and the
|
|
119
|
+
// route its own event rollup keys on (before HTTP inheritance).
|
|
107
120
|
const signalRollups = [];
|
|
108
121
|
const spans = [];
|
|
122
|
+
// Per span, what the request rollups need beyond the span row.
|
|
123
|
+
const spanFacts = [];
|
|
109
124
|
const llmCalls = [];
|
|
110
125
|
const rollups = new Map();
|
|
111
126
|
let spanCount = 0;
|
|
@@ -146,15 +161,20 @@ export function normalizeTraces(request) {
|
|
|
146
161
|
},
|
|
147
162
|
startedAt,
|
|
148
163
|
});
|
|
164
|
+
spanFacts.push({
|
|
165
|
+
process: resource.process,
|
|
166
|
+
routeQuality: routeQualityOf(attrs, route),
|
|
167
|
+
// Who counts this span's request, as its outermost server
|
|
168
|
+
// span: see countedBy.
|
|
169
|
+
countedBy: countedBy(resource, attrs),
|
|
170
|
+
templateRoute: attrs.has("http.route") && route ? normalizeRoute(route) : "",
|
|
171
|
+
});
|
|
149
172
|
// Error signals: one per exception event; if the span is
|
|
150
173
|
// errored without exception events, one from the span status.
|
|
151
174
|
// They become occurrences after the batch-wide dedupe below.
|
|
152
175
|
const traceId = span.traceId ?? "";
|
|
153
176
|
const spanId = span.spanId ?? "";
|
|
154
|
-
const rollup = {
|
|
155
|
-
onRequestRollup: kind === "server" && attrs.get("autter.tail_retained") !== "true",
|
|
156
|
-
route: route ?? "",
|
|
157
|
-
};
|
|
177
|
+
const rollup = { spanIndex: spans.length - 1, route: route ?? "" };
|
|
158
178
|
const spanAutter = attrs.has("autter.severity");
|
|
159
179
|
const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
|
|
160
180
|
for (const event of exceptionEvents) {
|
|
@@ -281,25 +301,6 @@ export function normalizeTraces(request) {
|
|
|
281
301
|
});
|
|
282
302
|
if (llmCall)
|
|
283
303
|
llmCalls.push(llmCall);
|
|
284
|
-
// Server spans fold into 1-minute usage rollups so traffic is
|
|
285
|
-
// tracked even when the metrics pipeline isn't wired. Spans
|
|
286
|
-
// exported by error-linked tail retention are skipped: the SDK
|
|
287
|
-
// ships those at ~100% alongside a metrics pipeline that already
|
|
288
|
-
// counts every request, so folding them in would double-count
|
|
289
|
-
// erroring routes.
|
|
290
|
-
if (rollup.onRequestRollup) {
|
|
291
|
-
addToRollup(rollups, {
|
|
292
|
-
service: resource.service,
|
|
293
|
-
environment: resource.environment,
|
|
294
|
-
release: resource.release,
|
|
295
|
-
route: normalizeRoute(route),
|
|
296
|
-
bucketAt: minuteBucket(startedAt),
|
|
297
|
-
requestCount: 1,
|
|
298
|
-
errorCount: isError ? 1 : 0,
|
|
299
|
-
durationSumMs: durationMs,
|
|
300
|
-
sessionCount: 0,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
306
|
}
|
|
@@ -308,30 +309,94 @@ export function normalizeTraces(request) {
|
|
|
308
309
|
// span chain explains are dropped (trace-dedupe.ts).
|
|
309
310
|
const deduped = dedupeTraceSignals(signals, spans);
|
|
310
311
|
const occurrences = deduped.occurrences;
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
312
|
+
// Usage rollups (`runtime_metrics_1m`): `request_count` counts requests
|
|
313
|
+
// and only requests, `error_count` counts failed requests and errors
|
|
314
|
+
// outside any request. A request is its outermost server span
|
|
315
|
+
// (request-spans.ts); its server spans count it unless the sender's metric
|
|
316
|
+
// feed does (countedBySpans), and nothing else adds requests.
|
|
317
|
+
const membership = requestMembership(spans.map((span, i) => ({
|
|
318
|
+
traceId: span.traceId,
|
|
319
|
+
spanId: span.spanId,
|
|
320
|
+
parentSpanId: span.parentSpanId,
|
|
321
|
+
kind: span.kind,
|
|
322
|
+
process: spanFacts[i].process,
|
|
323
|
+
})));
|
|
324
|
+
const requests = spanRequests(spans, spanFacts, membership);
|
|
325
|
+
const requestOfSignal = (m) => {
|
|
326
|
+
const root = membership.requestOf[signalRollups[m].spanIndex];
|
|
327
|
+
return root >= 0 ? requests.get(root) : undefined;
|
|
328
|
+
};
|
|
329
|
+
// Each error or fatal occurrence (after the dedupe: one per thrown error)
|
|
330
|
+
// counts once. One recorded in a request (on its server spans or any span
|
|
331
|
+
// below them in the batch) makes that request a failed one; any other
|
|
332
|
+
// (captureException's always-on spans exported apart from their request,
|
|
333
|
+
// workers, consumers, jobs) is one error outside a request. Warnings and
|
|
334
|
+
// info count nothing; neither does a dropped status-only occurrence.
|
|
335
|
+
const errorsOutsideRequests = [];
|
|
319
336
|
deduped.members.forEach((members, i) => {
|
|
320
|
-
if (members.some((m) => signalRollups[m].onRequestRollup))
|
|
321
|
-
return;
|
|
322
337
|
const occ = occurrences[i];
|
|
323
|
-
|
|
338
|
+
if (occ.severity !== "fatal" && occ.severity !== "error")
|
|
339
|
+
return;
|
|
340
|
+
const request = members.map(requestOfSignal).find((r) => r !== undefined);
|
|
341
|
+
if (request) {
|
|
342
|
+
request.hasErrorOccurrence = true;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
errorsOutsideRequests.push({
|
|
324
346
|
service: occ.service,
|
|
325
347
|
environment: occ.environment,
|
|
326
348
|
release: occ.release,
|
|
327
349
|
route: signalRollups[deduped.sourceIndexes[i]].route,
|
|
328
350
|
bucketAt: minuteBucket(occ.occurredAt),
|
|
329
|
-
requestCount:
|
|
330
|
-
errorCount:
|
|
351
|
+
requestCount: 0,
|
|
352
|
+
errorCount: 1,
|
|
331
353
|
durationSumMs: 0,
|
|
332
354
|
sessionCount: 0,
|
|
333
355
|
});
|
|
334
356
|
});
|
|
357
|
+
for (const [root, request] of requests) {
|
|
358
|
+
const span = spans[root];
|
|
359
|
+
const failed = request.isError || request.hasErrorOccurrence;
|
|
360
|
+
const point = {
|
|
361
|
+
service: span.service,
|
|
362
|
+
environment: span.environment,
|
|
363
|
+
release: span.release,
|
|
364
|
+
bucketAt: minuteBucket(span.startedAt),
|
|
365
|
+
sessionCount: 0,
|
|
366
|
+
};
|
|
367
|
+
switch (spanFacts[root].countedBy) {
|
|
368
|
+
case "spans":
|
|
369
|
+
addToRollup(rollups, {
|
|
370
|
+
...point,
|
|
371
|
+
route: request.route,
|
|
372
|
+
requestCount: 1,
|
|
373
|
+
errorCount: failed ? 1 : 0,
|
|
374
|
+
// The request's latency is its outermost span's.
|
|
375
|
+
durationSumMs: span.durationMs,
|
|
376
|
+
});
|
|
377
|
+
break;
|
|
378
|
+
case "metrics": {
|
|
379
|
+
// The metric feed counted it, and already made a 5xx response
|
|
380
|
+
// a failed request. A request it saw succeed that recorded an
|
|
381
|
+
// error adds that one error, on the route its point carried.
|
|
382
|
+
const metricCountedFailure = span.statusCode !== null && span.statusCode >= 500;
|
|
383
|
+
if (request.hasErrorOccurrence && !metricCountedFailure) {
|
|
384
|
+
addToRollup(rollups, { ...point, route: spanFacts[root].templateRoute, requestCount: 0, errorCount: 1, durationSumMs: 0 });
|
|
385
|
+
}
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "none":
|
|
389
|
+
// Exported because it failed, with no metric feed counting
|
|
390
|
+
// requests: not a sample of the traffic, so no request; its
|
|
391
|
+
// failure counts once.
|
|
392
|
+
if (failed) {
|
|
393
|
+
addToRollup(rollups, { ...point, route: request.route, requestCount: 0, errorCount: 1, durationSumMs: 0 });
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const error of errorsOutsideRequests)
|
|
399
|
+
addToRollup(rollups, error);
|
|
335
400
|
inheritHttpFromParent(occurrences, deduped.sourceIndexes.map((index) => signals[index].spanId), spans);
|
|
336
401
|
return {
|
|
337
402
|
occurrences,
|
|
@@ -342,6 +407,53 @@ export function normalizeTraces(request) {
|
|
|
342
407
|
spanCount,
|
|
343
408
|
};
|
|
344
409
|
}
|
|
410
|
+
function countedBy(resource, attrs) {
|
|
411
|
+
if (resource.metricsCountRoutedRequests && attrs.get("autter.metric_has_route") === "true")
|
|
412
|
+
return "metrics";
|
|
413
|
+
if (attrs.get("autter.tail_retained") === "true")
|
|
414
|
+
return resource.metricsWired ? "metrics" : "none";
|
|
415
|
+
return "spans";
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Every request in the batch, keyed by the batch index of its outermost
|
|
419
|
+
* server span (request-spans.ts). Server spans nested in it add no request:
|
|
420
|
+
* they lend their route when it is better (a template such as Next's
|
|
421
|
+
* `/blog/[slug]` over the HTTP span's raw path; on a tie the shallower
|
|
422
|
+
* span's), and their error. Latency and minute bucket stay the outermost
|
|
423
|
+
* span's.
|
|
424
|
+
*/
|
|
425
|
+
function spanRequests(spans, facts, membership) {
|
|
426
|
+
const requests = new Map();
|
|
427
|
+
spans.forEach((span, i) => {
|
|
428
|
+
if (membership.requestOf[i] !== i)
|
|
429
|
+
return;
|
|
430
|
+
requests.set(i, {
|
|
431
|
+
route: span.route ?? "",
|
|
432
|
+
routeQuality: facts[i].routeQuality,
|
|
433
|
+
routeDepth: membership.depth[i],
|
|
434
|
+
isError: span.status === "error",
|
|
435
|
+
hasErrorOccurrence: false,
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
spans.forEach((span, i) => {
|
|
439
|
+
const root = membership.requestOf[i];
|
|
440
|
+
if (root === i || span.kind !== "server")
|
|
441
|
+
return;
|
|
442
|
+
const request = requests.get(root);
|
|
443
|
+
if (!request)
|
|
444
|
+
return;
|
|
445
|
+
const quality = facts[i].routeQuality;
|
|
446
|
+
const depth = membership.depth[i];
|
|
447
|
+
if (quality > request.routeQuality || (quality === request.routeQuality && depth < request.routeDepth)) {
|
|
448
|
+
request.route = span.route ?? "";
|
|
449
|
+
request.routeQuality = quality;
|
|
450
|
+
request.routeDepth = depth;
|
|
451
|
+
}
|
|
452
|
+
if (span.status === "error")
|
|
453
|
+
request.isError = true;
|
|
454
|
+
});
|
|
455
|
+
return requests;
|
|
456
|
+
}
|
|
345
457
|
/**
|
|
346
458
|
* Exception spans are often children named "Error" with no HTTP attributes.
|
|
347
459
|
* Copy method, route, status, and duration from the server span in the same
|
|
@@ -438,8 +550,10 @@ const HTTP_DURATION_INSTRUMENTS = {
|
|
|
438
550
|
* Cumulative histograms report lifetime totals on every export; adding them
|
|
439
551
|
* into a SummingMergeTree would re-count all past requests each interval.
|
|
440
552
|
* Only deltas are summable — cumulative senders are skipped and covered by
|
|
441
|
-
* the span-fed rollup fallback instead
|
|
442
|
-
* `
|
|
553
|
+
* the span-fed rollup fallback instead (a sender must not mark itself
|
|
554
|
+
* `autter.metrics_wired` with cumulative histograms). (Enum arrives as a
|
|
555
|
+
* number or an `AGGREGATION_TEMPORALITY_*` string depending on the
|
|
556
|
+
* serialiser.)
|
|
443
557
|
*/
|
|
444
558
|
function isCumulativeTemporality(t) {
|
|
445
559
|
return t === 2 || t === "AGGREGATION_TEMPORALITY_CUMULATIVE";
|
|
@@ -448,6 +562,10 @@ export function normalizeMetrics(request) {
|
|
|
448
562
|
const rollups = new Map();
|
|
449
563
|
for (const resourceMetric of request.resourceMetrics ?? []) {
|
|
450
564
|
const resource = resourceInfo(resourceMetric.resource);
|
|
565
|
+
// An SDK migrating semantic conventions can emit both instruments for
|
|
566
|
+
// every request (OTel's `OTEL_SEMCONV_STABILITY_OPT_IN=http/dup`).
|
|
567
|
+
// Count the stable one, as the latency histograms do (latency.ts).
|
|
568
|
+
const hasStableDuration = resourceMetric.scopeMetrics?.some((scope) => scope.metrics?.some((metric) => metric.name === "http.server.request.duration"));
|
|
451
569
|
for (const scopeMetric of resourceMetric.scopeMetrics ?? []) {
|
|
452
570
|
for (const metric of scopeMetric.metrics ?? []) {
|
|
453
571
|
const multiplier = metric.name
|
|
@@ -455,6 +573,8 @@ export function normalizeMetrics(request) {
|
|
|
455
573
|
: undefined;
|
|
456
574
|
if (multiplier === undefined)
|
|
457
575
|
continue;
|
|
576
|
+
if (hasStableDuration && metric.name === "http.server.duration")
|
|
577
|
+
continue;
|
|
458
578
|
if (isCumulativeTemporality(metric.histogram?.aggregationTemporality)) {
|
|
459
579
|
continue;
|
|
460
580
|
}
|
package/dist/otlp-proto.d.ts
CHANGED
|
@@ -3,3 +3,17 @@ import type { OtlpLogsRequest } from "./logs.js";
|
|
|
3
3
|
export declare function decodeTraceRequest(body: Buffer): OtlpTraceRequest;
|
|
4
4
|
export declare function decodeMetricsRequest(body: Buffer): OtlpMetricsRequest;
|
|
5
5
|
export declare function decodeLogsRequest(body: Buffer): OtlpLogsRequest;
|
|
6
|
+
/**
|
|
7
|
+
* The protobuf body of an OTLP export response: empty when everything was
|
|
8
|
+
* accepted, else `partial_success` with the rejected count and why
|
|
9
|
+
* (identical bytes for traces, logs and metrics).
|
|
10
|
+
*/
|
|
11
|
+
export declare function encodeExportResponse(partial: {
|
|
12
|
+
rejected: number;
|
|
13
|
+
errorMessage: string;
|
|
14
|
+
} | null): Uint8Array;
|
|
15
|
+
/** Decode an OTLP export response body (tests, and anything reading our answers). */
|
|
16
|
+
export declare function decodeExportResponse(body: Uint8Array): {
|
|
17
|
+
rejected: number;
|
|
18
|
+
errorMessage: string;
|
|
19
|
+
} | null;
|
package/dist/otlp-proto.js
CHANGED
|
@@ -93,11 +93,19 @@ message HistogramDataPoint {
|
|
|
93
93
|
repeated double explicit_bounds = 7;
|
|
94
94
|
repeated KeyValue attributes = 9;
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
// ExportTraceServiceResponse, ExportLogsServiceResponse and
|
|
98
|
+
// ExportMetricsServiceResponse share this wire shape: partial_success = 1,
|
|
99
|
+
// and in it the rejected count (rejected_spans / rejected_log_records /
|
|
100
|
+
// rejected_data_points) = 1 and error_message = 2.
|
|
101
|
+
message ExportServiceResponse { ExportPartialSuccess partial_success = 1; }
|
|
102
|
+
message ExportPartialSuccess { int64 rejected = 1; string error_message = 2; }
|
|
96
103
|
`;
|
|
97
104
|
const root = protobuf.parse(PROTO).root;
|
|
98
105
|
const TraceRequest = root.lookupType("otlp.ExportTraceServiceRequest");
|
|
99
106
|
const MetricsRequest = root.lookupType("otlp.ExportMetricsServiceRequest");
|
|
100
107
|
const LogsRequest = root.lookupType("otlp.ExportLogsServiceRequest");
|
|
108
|
+
const ServiceResponse = root.lookupType("otlp.ExportServiceResponse");
|
|
101
109
|
const TO_OBJECT_OPTIONS = {
|
|
102
110
|
longs: String, // 64-bit ints → strings (matches OTLP/JSON)
|
|
103
111
|
defaults: false,
|
|
@@ -131,3 +139,20 @@ export function decodeMetricsRequest(body) {
|
|
|
131
139
|
export function decodeLogsRequest(body) {
|
|
132
140
|
return hexifyIds(LogsRequest.toObject(LogsRequest.decode(body), TO_OBJECT_OPTIONS));
|
|
133
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* The protobuf body of an OTLP export response: empty when everything was
|
|
144
|
+
* accepted, else `partial_success` with the rejected count and why
|
|
145
|
+
* (identical bytes for traces, logs and metrics).
|
|
146
|
+
*/
|
|
147
|
+
export function encodeExportResponse(partial) {
|
|
148
|
+
if (!partial)
|
|
149
|
+
return new Uint8Array(0);
|
|
150
|
+
return ServiceResponse.encode(ServiceResponse.create({ partialSuccess: { rejected: partial.rejected, errorMessage: partial.errorMessage } })).finish();
|
|
151
|
+
}
|
|
152
|
+
/** Decode an OTLP export response body (tests, and anything reading our answers). */
|
|
153
|
+
export function decodeExportResponse(body) {
|
|
154
|
+
const message = ServiceResponse.toObject(ServiceResponse.decode(body), { longs: Number, defaults: false });
|
|
155
|
+
if (!message.partialSuccess)
|
|
156
|
+
return null;
|
|
157
|
+
return { rejected: message.partialSuccess.rejected ?? 0, errorMessage: message.partialSuccess.errorMessage ?? "" };
|
|
158
|
+
}
|
package/dist/profiles.d.ts
CHANGED
|
@@ -13,10 +13,14 @@ export interface ProfileSample {
|
|
|
13
13
|
value: number;
|
|
14
14
|
}
|
|
15
15
|
export declare function profileTableDDL(db: string): string;
|
|
16
|
+
/** Samples kept per profile; the rest are dropped and counted (profile_samples_over_limit). */
|
|
17
|
+
export declare const MAX_PROFILE_SAMPLES = 1000;
|
|
16
18
|
export declare function decodeProfile(body: Buffer, meta: {
|
|
17
19
|
service: string;
|
|
18
20
|
environment: string;
|
|
19
21
|
release: string;
|
|
20
22
|
traceId: string;
|
|
21
23
|
instanceId: string;
|
|
24
|
+
}, stats?: {
|
|
25
|
+
overLimit: number;
|
|
22
26
|
}): ProfileSample[];
|
package/dist/profiles.js
CHANGED
|
@@ -28,7 +28,9 @@ export function profileTableDDL(db) {
|
|
|
28
28
|
ORDER BY (org_id, repository_id, service, environment, release, observed_at, profile_id)
|
|
29
29
|
TTL toDateTime(observed_at) + INTERVAL 7 DAY`;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
/** Samples kept per profile; the rest are dropped and counted (profile_samples_over_limit). */
|
|
32
|
+
export const MAX_PROFILE_SAMPLES = 1000;
|
|
33
|
+
export function decodeProfile(body, meta, stats) {
|
|
32
34
|
if (body.length === 0 || body.length > 1024 * 1024)
|
|
33
35
|
throw new Error("invalid profile size");
|
|
34
36
|
const payload = body[0] === 0x1f && body[1] === 0x8b
|
|
@@ -43,7 +45,9 @@ export function decodeProfile(body, meta) {
|
|
|
43
45
|
const observedAt = observedMs > 0 && observedMs <= Date.now() + 300_000 ? new Date(observedMs) : new Date();
|
|
44
46
|
const profileId = createHash("sha256").update(body).digest("hex").slice(0, 32);
|
|
45
47
|
const samples = [];
|
|
46
|
-
|
|
48
|
+
if (stats)
|
|
49
|
+
stats.overLimit += Math.max(0, (profile.sample ?? []).length - MAX_PROFILE_SAMPLES);
|
|
50
|
+
for (const [sampleIndex, sample] of (profile.sample ?? []).slice(0, MAX_PROFILE_SAMPLES).entries()) {
|
|
47
51
|
const stack = (sample.locationId ?? []).slice(0, 64).flatMap((id) => {
|
|
48
52
|
const locLine = locations.get(id)?.line?.[0];
|
|
49
53
|
const fn = locLine?.functionId ? functions.get(locLine.functionId) : undefined;
|