@monocle.sh/adonisjs-agent 1.0.0-beta.10

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.
@@ -0,0 +1,19 @@
1
+ import { ApplicationService } from "@adonisjs/core/types";
2
+
3
+ //#region providers/monocle_provider.d.ts
4
+ /**
5
+ * Stolen from @Adonisjs/otel to use another config filename. we need to expose this
6
+ * option from @adonis/otel so we can remove this file in the future.
7
+ */
8
+ declare class OtelProvider {
9
+ #private;
10
+ protected app: ApplicationService;
11
+ constructor(app: ApplicationService);
12
+ register(): void;
13
+ /**
14
+ * Gracefully flush pending spans
15
+ */
16
+ shutdown(): Promise<void>;
17
+ }
18
+ //#endregion
19
+ export { OtelProvider as default };
@@ -0,0 +1,66 @@
1
+ import { ExceptionReporter, toHttpError } from "./src/exception_reporter.mjs";
2
+ import { getCurrentSpan } from "@adonisjs/otel/helpers";
3
+ import OtelMiddleware from "@adonisjs/otel/otel_middleware";
4
+ import { OtelManager } from "@adonisjs/otel";
5
+ import { ExceptionHandler } from "@adonisjs/core/http";
6
+ import { configProvider } from "@adonisjs/core";
7
+
8
+ //#region providers/monocle_provider.ts
9
+ /**
10
+ * Stolen from @Adonisjs/otel to use another config filename. we need to expose this
11
+ * option from @adonis/otel so we can remove this file in the future.
12
+ */
13
+ var OtelProvider = class {
14
+ constructor(app) {
15
+ this.app = app;
16
+ }
17
+ /**
18
+ * Hook into ExceptionHandler to record exceptions in spans.
19
+ *
20
+ * We always record the exception on the span (for trace visibility), but we also
21
+ * add an attribute `monocle.exception.should_report` to indicate whether this
22
+ * exception should appear in the Exceptions dashboard.
23
+ *
24
+ * This respects the AdonisJS `ignoreExceptions`, `ignoreStatuses`, and `ignoreCodes`
25
+ * configuration from the ExceptionHandler.
26
+ */
27
+ #registerExceptionHandler() {
28
+ const originalReport = ExceptionHandler.prototype.report;
29
+ const reporter = new ExceptionReporter();
30
+ ExceptionHandler.macro("report", async function(error, ctx) {
31
+ const span = getCurrentSpan();
32
+ if (span) {
33
+ const httpError = toHttpError(error);
34
+ const shouldReport = this.shouldReport(httpError);
35
+ await reporter.report({
36
+ span,
37
+ error,
38
+ shouldReport
39
+ });
40
+ }
41
+ return originalReport.call(this, error, ctx);
42
+ });
43
+ }
44
+ register() {
45
+ this.#registerExceptionHandler();
46
+ this.#registerMiddleware();
47
+ }
48
+ /**
49
+ * Register the OtelMiddleware as a singleton in the container
50
+ */
51
+ #registerMiddleware() {
52
+ this.app.container.singleton(OtelMiddleware, async () => {
53
+ const otelConfigProvider = this.app.config.get("monocle", {});
54
+ return new OtelMiddleware({ userContext: (await configProvider.resolve(this.app, otelConfigProvider))?.userContext });
55
+ });
56
+ }
57
+ /**
58
+ * Gracefully flush pending spans
59
+ */
60
+ async shutdown() {
61
+ await OtelManager.getInstance()?.shutdown();
62
+ }
63
+ };
64
+
65
+ //#endregion
66
+ export { OtelProvider as default };
@@ -0,0 +1,127 @@
1
+ import { ExceptionReporter } from "./exception_reporter.mjs";
2
+ import { createRequire } from "node:module";
3
+ import { SpanKind, context, trace } from "@opentelemetry/api";
4
+
5
+ //#region src/cli_instrumentation.ts
6
+ /**
7
+ * Default commands to exclude from tracing.
8
+ * These are typically scaffolding commands or long-running workers.
9
+ */
10
+ const DEFAULT_EXCLUDE = [
11
+ "make:*",
12
+ "generate:*",
13
+ "queue:work",
14
+ "queue:listen"
15
+ ];
16
+ /**
17
+ * Simple glob pattern matcher supporting only '*' wildcard
18
+ */
19
+ function matchPattern(commandName, pattern) {
20
+ if (pattern === "*") return true;
21
+ if (!pattern.includes("*")) return commandName === pattern;
22
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
23
+ return new RegExp(`^${escaped.replace(/\*/g, ".*")}$`).test(commandName);
24
+ }
25
+ /**
26
+ * Extract command arguments from the instance
27
+ */
28
+ function extractArgs(command) {
29
+ const argsDef = command.constructor.args;
30
+ if (!argsDef || !Array.isArray(argsDef)) return {};
31
+ const result = {};
32
+ for (const arg of argsDef) {
33
+ const value = command[arg.name];
34
+ if (value !== void 0 && value !== null) result[arg.argumentName] = String(value);
35
+ }
36
+ return result;
37
+ }
38
+ /**
39
+ * Extract command flags from the instance
40
+ */
41
+ function extractFlags(command) {
42
+ const flagsDef = command.constructor.flags;
43
+ if (!flagsDef || !Array.isArray(flagsDef)) return {};
44
+ const result = {};
45
+ for (const flag of flagsDef) {
46
+ const value = command[flag.name];
47
+ if (value !== void 0 && value !== null && value !== false) result[flag.flagName] = String(value);
48
+ }
49
+ return result;
50
+ }
51
+ /**
52
+ * Check if a command should be traced based on include/exclude patterns
53
+ */
54
+ function shouldTraceCommand(commandName, config) {
55
+ if ((config.exclude ?? DEFAULT_EXCLUDE).some((pattern) => matchPattern(commandName, pattern))) return false;
56
+ if (config.include && config.include.length > 0) return config.include.some((pattern) => matchPattern(commandName, pattern));
57
+ return true;
58
+ }
59
+ /**
60
+ * Instrument AdonisJS Ace CLI commands with OpenTelemetry tracing.
61
+ *
62
+ * Uses monkey-patching of BaseCommand.prototype.exec to wrap command
63
+ * execution with an active OTEL span context. This ensures all child
64
+ * spans created during command execution are properly linked to the
65
+ * CLI command span.
66
+ *
67
+ * @param config - CLI tracing configuration
68
+ * @param appRoot - Application root directory path
69
+ */
70
+ async function instrumentCliCommands(config, appRoot) {
71
+ const tracer = trace.getTracer("@monocle.sh/adonisjs-agent", "1.0.0");
72
+ const BaseCommand = (await import(createRequire(appRoot ? `file://${appRoot}/package.json` : import.meta.url).resolve("@adonisjs/core/ace"))).BaseCommand;
73
+ const originalExec = BaseCommand.prototype.exec;
74
+ BaseCommand.prototype.exec = async function() {
75
+ const commandName = this.constructor.commandName;
76
+ const commandDescription = this.constructor.description;
77
+ if (!shouldTraceCommand(commandName, config)) return originalExec.call(this);
78
+ const args = extractArgs(this);
79
+ const flags = extractFlags(this);
80
+ const attributes = {
81
+ "entry_point.type": "cli",
82
+ "cli.command.name": commandName,
83
+ "cli.command.description": commandDescription || ""
84
+ };
85
+ for (const [key, value] of Object.entries(args)) attributes[`cli.args.${key}`] = value;
86
+ for (const [key, value] of Object.entries(flags)) attributes[`cli.flags.${key}`] = value;
87
+ const span = tracer.startSpan(`cli ${commandName}`, {
88
+ kind: SpanKind.INTERNAL,
89
+ attributes
90
+ });
91
+ const ctx = trace.setSpan(context.active(), span);
92
+ /**
93
+ * CRITICAL: Execute within context.with()
94
+ *
95
+ * This is what makes child spans work! Without this wrapper:
96
+ * - Child spans would have no parent
97
+ * - All spans would be root spans with different traceIds
98
+ * - The trace would be fragmented
99
+ *
100
+ * context.with() sets our span as the "active" span for all async
101
+ * operations within the callback, enabling proper trace propagation.
102
+ */
103
+ return context.with(ctx, async () => {
104
+ try {
105
+ const result = await originalExec.call(this);
106
+ if (this.error) await new ExceptionReporter().report({
107
+ span,
108
+ error: this.error,
109
+ shouldReport: true
110
+ });
111
+ return result;
112
+ } catch (error) {
113
+ await new ExceptionReporter().report({
114
+ span,
115
+ error,
116
+ shouldReport: true
117
+ });
118
+ throw error;
119
+ } finally {
120
+ span.end();
121
+ }
122
+ });
123
+ };
124
+ }
125
+
126
+ //#endregion
127
+ export { instrumentCliCommands };
@@ -0,0 +1,269 @@
1
+ import { MAX_CAUSE_DEPTH, getErrorCause, isErrorLike } from "./error_serializer.mjs";
2
+ import { createInterface } from "node:readline";
3
+ import { createReadStream } from "node:fs";
4
+ import { parseStack } from "error-stack-parser-es/lite";
5
+
6
+ //#region src/context_lines.ts
7
+ const DEFAULT_CONTEXT_LINES = 7;
8
+ const MAX_LINENO = 1e4;
9
+ const MAX_COLNO = 1e3;
10
+ const MAX_LINE_LENGTH = 200;
11
+ const FILE_CACHE_SIZE = 10;
12
+ const FAILED_CACHE_SIZE = 20;
13
+ /**
14
+ * Simple LRU cache implementation.
15
+ */
16
+ var LRUCache = class {
17
+ #maxSize;
18
+ #cache = /* @__PURE__ */ new Map();
19
+ constructor(maxSize) {
20
+ this.#maxSize = maxSize;
21
+ }
22
+ get(key) {
23
+ const value = this.#cache.get(key);
24
+ if (value === void 0) return void 0;
25
+ this.#cache.delete(key);
26
+ this.#cache.set(key, value);
27
+ return value;
28
+ }
29
+ set(key, value) {
30
+ this.#cache.delete(key);
31
+ if (this.#cache.size >= this.#maxSize) {
32
+ const oldestKey = this.#cache.keys().next().value;
33
+ if (oldestKey !== void 0) this.#cache.delete(oldestKey);
34
+ }
35
+ this.#cache.set(key, value);
36
+ }
37
+ has(key) {
38
+ return this.#cache.has(key);
39
+ }
40
+ clear() {
41
+ this.#cache.clear();
42
+ }
43
+ };
44
+ const fileCache = new LRUCache(FILE_CACHE_SIZE);
45
+ const failedCache = new LRUCache(FAILED_CACHE_SIZE);
46
+ /**
47
+ * Normalize file path by stripping file:// protocol.
48
+ */
49
+ function normalizeFilePath(path) {
50
+ if (path.startsWith("file://")) return path.slice(7);
51
+ return path;
52
+ }
53
+ /**
54
+ * Check if a file should be skipped for context extraction.
55
+ */
56
+ function shouldSkipFile(path) {
57
+ if (path.startsWith("node:")) return true;
58
+ if (path.startsWith("data:")) return true;
59
+ if (path.endsWith(".min.js")) return true;
60
+ if (path.endsWith(".min.cjs")) return true;
61
+ if (path.endsWith(".min.mjs")) return true;
62
+ if (path.includes("/node_modules/")) return true;
63
+ return false;
64
+ }
65
+ /**
66
+ * Check if a frame should be skipped based on line/column numbers.
67
+ */
68
+ function shouldSkipFrame(frame) {
69
+ if (frame.line === void 0) return true;
70
+ if (frame.line > MAX_LINENO) return true;
71
+ if (frame.col !== void 0 && frame.col > MAX_COLNO) return true;
72
+ return false;
73
+ }
74
+ /**
75
+ * Check if a frame is from the application code (not node_modules).
76
+ */
77
+ function isInApp(filename) {
78
+ return !filename.includes("/node_modules/");
79
+ }
80
+ /**
81
+ * Truncate long lines to prevent huge payloads.
82
+ */
83
+ function snipLine(line) {
84
+ if (line.length <= MAX_LINE_LENGTH) return line;
85
+ return line.slice(0, MAX_LINE_LENGTH) + "...";
86
+ }
87
+ /**
88
+ * Create context range around a line number.
89
+ */
90
+ function makeContextRange(lineno, contextLines) {
91
+ return [Math.max(1, lineno - contextLines), lineno + contextLines];
92
+ }
93
+ /**
94
+ * Merge overlapping line ranges for efficient file reading.
95
+ */
96
+ function mergeRanges(ranges) {
97
+ if (ranges.length === 0) return [];
98
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
99
+ const merged = [sorted[0]];
100
+ for (let i = 1; i < sorted.length; i++) {
101
+ const current = sorted[i];
102
+ const last = merged[merged.length - 1];
103
+ if (current[0] <= last[1] + 1) last[1] = Math.max(last[1], current[1]);
104
+ else merged.push(current);
105
+ }
106
+ return merged;
107
+ }
108
+ /**
109
+ * Read specific line ranges from a file.
110
+ * Follows Sentry's battle-tested patterns for stream handling.
111
+ */
112
+ async function readFileLines(filepath, ranges) {
113
+ return new Promise((resolve) => {
114
+ const output = {};
115
+ let resolved = false;
116
+ const stream = createReadStream(filepath);
117
+ const lineReader = createInterface({ input: stream });
118
+ function destroyStreamAndResolve() {
119
+ if (resolved) return;
120
+ resolved = true;
121
+ stream.destroy();
122
+ resolve(output);
123
+ }
124
+ if (ranges.length === 0) {
125
+ destroyStreamAndResolve();
126
+ return;
127
+ }
128
+ let lineNumber = 0;
129
+ let currentRangeIndex = 0;
130
+ let rangeStart = ranges[0][0];
131
+ let rangeEnd = ranges[0][1];
132
+ function onStreamError() {
133
+ failedCache.set(filepath, true);
134
+ lineReader.close();
135
+ lineReader.removeAllListeners();
136
+ destroyStreamAndResolve();
137
+ }
138
+ stream.on("error", onStreamError);
139
+ lineReader.on("error", onStreamError);
140
+ lineReader.on("close", destroyStreamAndResolve);
141
+ lineReader.on("line", (line) => {
142
+ try {
143
+ lineNumber++;
144
+ if (lineNumber < rangeStart) return;
145
+ output[lineNumber] = snipLine(line);
146
+ if (lineNumber >= rangeEnd) {
147
+ if (currentRangeIndex === ranges.length - 1) {
148
+ lineReader.close();
149
+ lineReader.removeAllListeners();
150
+ return;
151
+ }
152
+ currentRangeIndex++;
153
+ const nextRange = ranges[currentRangeIndex];
154
+ rangeStart = nextRange[0];
155
+ rangeEnd = nextRange[1];
156
+ }
157
+ } catch {}
158
+ });
159
+ });
160
+ }
161
+ /**
162
+ * Extract context lines for a stack trace.
163
+ */
164
+ async function extractContextLines(stack, contextLines = DEFAULT_CONTEXT_LINES) {
165
+ const parsedFrames = parseStack(stack);
166
+ const framesToEnrich = [];
167
+ for (const frame of parsedFrames) {
168
+ if (!frame.file || !frame.line) continue;
169
+ const filepath = normalizeFilePath(frame.file);
170
+ if (shouldSkipFile(filepath)) continue;
171
+ if (shouldSkipFrame(frame)) continue;
172
+ const range = makeContextRange(frame.line, contextLines);
173
+ framesToEnrich.push({
174
+ frame,
175
+ filepath,
176
+ range
177
+ });
178
+ }
179
+ if (framesToEnrich.length === 0) return [];
180
+ const fileToRanges = /* @__PURE__ */ new Map();
181
+ for (const { filepath, range } of framesToEnrich) {
182
+ const existing = fileToRanges.get(filepath) || [];
183
+ existing.push(range);
184
+ fileToRanges.set(filepath, existing);
185
+ }
186
+ const readPromises = [];
187
+ for (const [filepath, ranges] of fileToRanges) {
188
+ if (failedCache.has(filepath)) continue;
189
+ const cachedContent = fileCache.get(filepath);
190
+ const mergedRanges = mergeRanges(ranges);
191
+ if (cachedContent && mergedRanges.every(([start, end]) => {
192
+ for (let i = start; i <= end; i++) if (cachedContent[i] === void 0) return false;
193
+ return true;
194
+ })) continue;
195
+ readPromises.push(readFileLines(filepath, mergedRanges).then((lines) => {
196
+ const existing = fileCache.get(filepath) || {};
197
+ fileCache.set(filepath, {
198
+ ...existing,
199
+ ...lines
200
+ });
201
+ }));
202
+ }
203
+ await Promise.all(readPromises).catch(() => {});
204
+ const enrichedFrames = [];
205
+ for (const { frame, filepath, range } of framesToEnrich) {
206
+ const fileContent = fileCache.get(filepath);
207
+ if (!fileContent) continue;
208
+ const [rangeStart, rangeEnd] = range;
209
+ const preContext = [];
210
+ const postContext = [];
211
+ for (let i = rangeStart; i < frame.line; i++) {
212
+ const line = fileContent[i];
213
+ if (line !== void 0) preContext.push(line);
214
+ }
215
+ const contextLine = fileContent[frame.line];
216
+ if (contextLine === void 0) continue;
217
+ for (let i = frame.line + 1; i <= rangeEnd; i++) {
218
+ const line = fileContent[i];
219
+ if (line !== void 0) postContext.push(line);
220
+ }
221
+ enrichedFrames.push({
222
+ filename: filepath,
223
+ lineno: frame.line,
224
+ colno: frame.col,
225
+ function: frame.function,
226
+ in_app: isInApp(filepath),
227
+ pre_context: preContext,
228
+ context_line: contextLine,
229
+ post_context: postContext
230
+ });
231
+ }
232
+ return enrichedFrames;
233
+ }
234
+ /**
235
+ * Build a cause chain with context lines for each cause.
236
+ * This extracts context lines from each error's stack trace.
237
+ */
238
+ async function buildCauseChainWithContext(error, contextLines = DEFAULT_CONTEXT_LINES) {
239
+ return _buildCauseChainWithContext(error, /* @__PURE__ */ new Set(), 0, contextLines);
240
+ }
241
+ async function _buildCauseChainWithContext(err, seen, depth, contextLines) {
242
+ if (!isErrorLike(err)) return [];
243
+ if (depth >= MAX_CAUSE_DEPTH) return [{
244
+ type: "Error",
245
+ message: "(max cause depth reached)"
246
+ }];
247
+ if (seen.has(err)) return [{
248
+ type: "Error",
249
+ message: "(circular reference detected)"
250
+ }];
251
+ seen.add(err);
252
+ let frames;
253
+ if (err.stack) try {
254
+ const extracted = await extractContextLines(err.stack, contextLines);
255
+ if (extracted.length > 0) frames = extracted;
256
+ } catch {}
257
+ const current = {
258
+ type: err.name || "Error",
259
+ message: err.message || "",
260
+ stack: err.stack,
261
+ frames
262
+ };
263
+ const cause = getErrorCause(err);
264
+ if (cause) return [current, ...await _buildCauseChainWithContext(cause, seen, depth + 1, contextLines)];
265
+ return [current];
266
+ }
267
+
268
+ //#endregion
269
+ export { buildCauseChainWithContext, extractContextLines };
@@ -0,0 +1,11 @@
1
+ import { MonocleConfig } from "./types.mjs";
2
+
3
+ //#region src/define_config.d.ts
4
+ /**
5
+ * Define and validate Monocle agent configuration.
6
+ * Sets up environment variables for OTEL exporters to point to Monocle.
7
+ * Returns undefined if no API key is provided (telemetry will be disabled).
8
+ */
9
+ declare function defineConfig(config: MonocleConfig): MonocleConfig | undefined;
10
+ //#endregion
11
+ export { defineConfig };
@@ -0,0 +1,72 @@
1
+ //#region src/define_config.ts
2
+ /**
3
+ * Extracts a header value from either ServerResponse (getHeader) or IncomingMessage (headers object).
4
+ */
5
+ function getHeader(response, name) {
6
+ if ("getHeader" in response && typeof response.getHeader === "function") {
7
+ const value = response.getHeader(name);
8
+ if (typeof value === "string") return value;
9
+ }
10
+ if ("headers" in response && response.headers) {
11
+ const value = response.headers[name];
12
+ if (typeof value === "string") return value;
13
+ }
14
+ }
15
+ /**
16
+ * Detects the connection type based on response headers.
17
+ * Returns 'sse' for Server-Sent Events, 'websocket' for WebSocket upgrades,
18
+ * or undefined for standard HTTP connections.
19
+ */
20
+ function detectConnectionType(response) {
21
+ if (getHeader(response, "content-type")?.includes("text/event-stream")) return "sse";
22
+ if (getHeader(response, "upgrade")?.toLowerCase() === "websocket") return "websocket";
23
+ }
24
+ /**
25
+ * Creates a response hook that detects long-running HTTP connections
26
+ * (SSE and WebSocket) and adds the `http.connection_type` attribute.
27
+ */
28
+ function createConnectionTypeHook(userHook) {
29
+ return (span, response) => {
30
+ const connectionType = detectConnectionType(response);
31
+ if (connectionType) span.setAttribute("http.connection_type", connectionType);
32
+ userHook?.(span, response);
33
+ };
34
+ }
35
+ /**
36
+ * Extracts the user-defined response hook from HTTP instrumentation config.
37
+ */
38
+ function extractUserResponseHook(httpConfig) {
39
+ if (typeof httpConfig !== "object" || httpConfig === null) return;
40
+ if (!("responseHook" in httpConfig)) return;
41
+ return httpConfig.responseHook;
42
+ }
43
+ /**
44
+ * Define and validate Monocle agent configuration.
45
+ * Sets up environment variables for OTEL exporters to point to Monocle.
46
+ * Returns undefined if no API key is provided (telemetry will be disabled).
47
+ */
48
+ function defineConfig(config) {
49
+ if (!config.apiKey) return;
50
+ const endpoint = config.endpoint || "https://ingest.monocle.sh";
51
+ const environment = config.environment || process.env.NODE_ENV || "development";
52
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint;
53
+ process.env.OTEL_EXPORTER_OTLP_HEADERS = `x-api-key=${config.apiKey},x-monocle-env=${environment}`;
54
+ const httpConfig = config.instrumentations?.["@opentelemetry/instrumentation-http"];
55
+ const userResponseHook = extractUserResponseHook(httpConfig);
56
+ const instrumentations = {
57
+ ...config.instrumentations,
58
+ "@opentelemetry/instrumentation-http": {
59
+ ...typeof httpConfig === "object" && httpConfig !== null ? httpConfig : {},
60
+ responseHook: createConnectionTypeHook(userResponseHook)
61
+ }
62
+ };
63
+ return {
64
+ ...config,
65
+ endpoint,
66
+ environment,
67
+ instrumentations
68
+ };
69
+ }
70
+
71
+ //#endregion
72
+ export { defineConfig };
@@ -0,0 +1,77 @@
1
+ //#region src/error_serializer.ts
2
+ /**
3
+ * Maximum number of causes to capture (prevents infinite loops).
4
+ */
5
+ const MAX_CAUSE_DEPTH = 5;
6
+ /**
7
+ * Check if a value is error-like (has a message property).
8
+ */
9
+ function isErrorLike(err) {
10
+ return err != null && typeof err.message === "string";
11
+ }
12
+ /**
13
+ * Safely extract the cause from an error.
14
+ * Handles both standard Error.cause and VError-style cause() functions.
15
+ */
16
+ function getErrorCause(err) {
17
+ if (!err) return void 0;
18
+ const cause = err.cause;
19
+ if (typeof cause === "function") {
20
+ const causeResult = cause();
21
+ return isErrorLike(causeResult) ? causeResult : void 0;
22
+ }
23
+ return isErrorLike(cause) ? cause : void 0;
24
+ }
25
+ /**
26
+ * Build a combined stack trace from an error and all its causes.
27
+ * Format:
28
+ * Error: Top-level message
29
+ * at file.js:10:5
30
+ * caused by: Error: Mid-level message
31
+ * at file.js:20:3
32
+ */
33
+ function stackWithCauses(err) {
34
+ return _stackWithCauses(err, /* @__PURE__ */ new Set());
35
+ }
36
+ function _stackWithCauses(err, seen) {
37
+ if (!isErrorLike(err)) return "";
38
+ const stack = err.stack || "";
39
+ if (seen.has(err)) return stack + "\ncauses have become circular...";
40
+ const cause = getErrorCause(err);
41
+ if (cause) {
42
+ seen.add(err);
43
+ return stack + "\ncaused by: " + _stackWithCauses(cause, seen);
44
+ }
45
+ return stack;
46
+ }
47
+ /**
48
+ * Build a structured cause chain from an error.
49
+ * Returns an array of { type, message, stack } objects.
50
+ * Limited to MAX_CAUSE_DEPTH to prevent infinite loops.
51
+ */
52
+ function buildCauseChain(err) {
53
+ return _buildCauseChain(err, /* @__PURE__ */ new Set(), 0);
54
+ }
55
+ function _buildCauseChain(err, seen, depth) {
56
+ if (!isErrorLike(err)) return [];
57
+ if (depth >= MAX_CAUSE_DEPTH) return [{
58
+ type: "Error",
59
+ message: "(max cause depth reached)"
60
+ }];
61
+ if (seen.has(err)) return [{
62
+ type: "Error",
63
+ message: "(circular reference detected)"
64
+ }];
65
+ seen.add(err);
66
+ const current = {
67
+ type: err.name || "Error",
68
+ message: err.message || "",
69
+ stack: err.stack
70
+ };
71
+ const cause = getErrorCause(err);
72
+ if (cause) return [current, ..._buildCauseChain(cause, seen, depth + 1)];
73
+ return [current];
74
+ }
75
+
76
+ //#endregion
77
+ export { MAX_CAUSE_DEPTH, buildCauseChain, getErrorCause, isErrorLike, stackWithCauses };
@@ -0,0 +1,52 @@
1
+ import { buildCauseChain, stackWithCauses } from "./error_serializer.mjs";
2
+ import { buildCauseChainWithContext } from "./context_lines.mjs";
3
+ import { SpanStatusCode } from "@opentelemetry/api";
4
+ import is from "@sindresorhus/is";
5
+
6
+ //#region src/exception_reporter.ts
7
+ const DEFAULT_CONTEXT_LINES = 7;
8
+ /**
9
+ * Convert any unknown error to an HttpError-like object.
10
+ */
11
+ function toHttpError(error) {
12
+ const httpError = is.object(error) ? error : new Error(String(error));
13
+ if (!httpError.message) httpError.message = "Internal server error";
14
+ if (!httpError.status) httpError.status = 500;
15
+ return httpError;
16
+ }
17
+ /**
18
+ * Reports exceptions to OpenTelemetry spans with context lines and cause chains.
19
+ */
20
+ var ExceptionReporter = class {
21
+ /**
22
+ * Report an exception on the given span.
23
+ */
24
+ async report(options) {
25
+ const { span, shouldReport, contextLines = DEFAULT_CONTEXT_LINES } = options;
26
+ const error = toHttpError(options.error);
27
+ span.setStatus({
28
+ code: SpanStatusCode.ERROR,
29
+ message: error.message
30
+ });
31
+ span.setAttribute("monocle.exception.should_report", shouldReport);
32
+ const causeChain = shouldReport ? await buildCauseChainWithContext(error, contextLines) : buildCauseChain(error);
33
+ const attributes = this.#buildAttributes(error, causeChain);
34
+ span.addEvent("exception", attributes);
35
+ }
36
+ #buildAttributes(error, causeChain) {
37
+ const hasCauses = causeChain.length > 1;
38
+ const attributes = {
39
+ "exception.type": error.name || "Error",
40
+ "exception.message": error.message || "",
41
+ "exception.stacktrace": hasCauses ? stackWithCauses(error) : error.stack || ""
42
+ };
43
+ const actualCauses = causeChain.slice(1);
44
+ if (actualCauses.length > 0) attributes["monocle.exception.cause_chain"] = JSON.stringify(actualCauses);
45
+ const mainFrames = causeChain[0]?.frames;
46
+ if (mainFrames && mainFrames.length > 0) attributes["monocle.exception.frames"] = JSON.stringify(mainFrames);
47
+ return attributes;
48
+ }
49
+ };
50
+
51
+ //#endregion
52
+ export { ExceptionReporter, toHttpError };