@tracepanel/js 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +271 -2
- package/dist/agent.d.ts +42 -0
- package/dist/agent.js +298 -0
- package/dist/agent.js.map +1 -0
- package/dist/api.d.ts +11 -0
- package/dist/api.js +34 -0
- package/dist/api.js.map +1 -0
- package/dist/browser.d.ts +25 -0
- package/dist/browser.js +438 -0
- package/dist/browser.js.map +1 -0
- package/dist/bullmq.d.ts +7 -0
- package/dist/bullmq.js +86 -0
- package/dist/bullmq.js.map +1 -0
- package/dist/bun.d.ts +15 -0
- package/dist/bun.js +114 -0
- package/dist/bun.js.map +1 -0
- package/dist/calls.d.ts +20 -0
- package/dist/calls.js +145 -0
- package/dist/calls.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +48 -0
- package/dist/config.js.map +1 -0
- package/dist/console.d.ts +6 -0
- package/dist/console.js +44 -0
- package/dist/console.js.map +1 -0
- package/dist/explain.d.ts +25 -0
- package/dist/explain.js +145 -0
- package/dist/explain.js.map +1 -0
- package/dist/express.d.ts +2 -0
- package/dist/express.js +77 -0
- package/dist/express.js.map +1 -0
- package/dist/frameworks.d.ts +2 -0
- package/dist/frameworks.js +39 -0
- package/dist/frameworks.js.map +1 -0
- package/dist/headers.d.ts +2 -0
- package/dist/headers.js +42 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/logs.d.ts +8 -0
- package/dist/logs.js +54 -0
- package/dist/logs.js.map +1 -0
- package/dist/model.d.ts +68 -0
- package/dist/model.js +41 -0
- package/dist/model.js.map +1 -0
- package/dist/next-widget.d.ts +3 -0
- package/dist/next-widget.js +17 -0
- package/dist/next-widget.js.map +1 -0
- package/dist/next.d.ts +2 -0
- package/dist/next.js +16 -0
- package/dist/next.js.map +1 -0
- package/dist/nuxt-plugin.d.ts +24 -0
- package/dist/nuxt-plugin.js +39 -0
- package/dist/nuxt-plugin.js.map +1 -0
- package/dist/nuxt.d.ts +10 -0
- package/dist/nuxt.js +16 -0
- package/dist/nuxt.js.map +1 -0
- package/dist/processor.d.ts +105 -0
- package/dist/processor.js +491 -0
- package/dist/processor.js.map +1 -0
- package/dist/register.d.ts +1 -0
- package/dist/register.js +5 -0
- package/dist/register.js.map +1 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +27 -0
- package/dist/runtime.js.map +1 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +102 -0
- package/dist/setup.js.map +1 -0
- package/dist/sql.d.ts +3 -0
- package/dist/sql.js +18 -0
- package/dist/sql.js.map +1 -0
- package/dist/stack.d.ts +13 -0
- package/dist/stack.js +66 -0
- package/dist/stack.js.map +1 -0
- package/dist/vite.d.ts +36 -0
- package/dist/vite.js +139 -0
- package/dist/vite.js.map +1 -0
- package/dist/widget.d.ts +26 -0
- package/dist/widget.js +70 -0
- package/dist/widget.js.map +1 -0
- package/package.json +125 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The Nitro plugin @tracepanel/js/nuxt adds in development. Nitro bundles it into the server: it reads the process's
|
|
2
|
+
// Tracepanel from globalThis and brings no OpenTelemetry of its own.
|
|
3
|
+
import { captureConsole } from "./console.js";
|
|
4
|
+
import { headersOnResponses } from "./headers.js";
|
|
5
|
+
const KEY = Symbol.for("tracepanel.runtime");
|
|
6
|
+
export default function tracepanelPlugin(nitroApp) {
|
|
7
|
+
const tracepanel = globalThis[KEY];
|
|
8
|
+
if (!tracepanel)
|
|
9
|
+
return;
|
|
10
|
+
headersOnResponses(tracepanel, "Nuxt");
|
|
11
|
+
// Nuxt's dev server replaced console with its own logger after --import wrapped it.
|
|
12
|
+
captureConsole(tracepanel.processor);
|
|
13
|
+
// h3 answers a thrown error with a 500 and records it nowhere: the request's trace gets it here.
|
|
14
|
+
// createError({ statusCode: 404 }) and the like are answers, not errors.
|
|
15
|
+
nitroApp.hooks.hook("error", (error) => {
|
|
16
|
+
const status = error.statusCode ?? 500;
|
|
17
|
+
if (status >= 500)
|
|
18
|
+
tracepanel.processor.addError(error);
|
|
19
|
+
});
|
|
20
|
+
nitroApp.hooks.hook("request", () => tracepanel.processor.setFramework("Nuxt"));
|
|
21
|
+
// The route is matched by then. The renderer's catch-all (/**) serves every page: its path names a page better.
|
|
22
|
+
nitroApp.hooks.hook("beforeResponse", (event) => {
|
|
23
|
+
const route = event.context.matchedRoute?.path;
|
|
24
|
+
if (route && !route.includes("**"))
|
|
25
|
+
tracepanel.processor.setRoute(route);
|
|
26
|
+
});
|
|
27
|
+
nitroApp.hooks.hook("render:html", (html, { event }) => {
|
|
28
|
+
const marks = tracepanel.badge({
|
|
29
|
+
method: event.method ?? "GET",
|
|
30
|
+
status: 200,
|
|
31
|
+
startedMs: performance.now(),
|
|
32
|
+
page: true,
|
|
33
|
+
framework: tracepanel.processor.framework() ?? "Nuxt",
|
|
34
|
+
});
|
|
35
|
+
if (marks?.script)
|
|
36
|
+
html.head.unshift(marks.script);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=nuxt-plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nuxt-plugin.js","sourceRoot":"","sources":["../src/nuxt-plugin.ts"],"names":[],"mappings":"AAAA,qHAAqH;AACrH,qEAAqE;AAErE,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGlD,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;AAgB7C,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,QAAkB;IACzD,MAAM,UAAU,GAAI,UAAkC,CAAC,GAAG,CAAC,CAAC;IAC5D,IAAI,CAAC,UAAU;QAAE,OAAO;IACxB,kBAAkB,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;IACvC,oFAAoF;IACpF,cAAc,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACrC,iGAAiG;IACjG,yEAAyE;IACzE,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;QACrC,MAAM,MAAM,GAAI,KAAiC,CAAC,UAAU,IAAI,GAAG,CAAC;QACpE,IAAI,MAAM,IAAI,GAAG;YAAE,UAAU,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IACH,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;IAChF,gHAAgH;IAChH,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC,KAAK,EAAE,EAAE;QAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,YAAY,EAAE,IAAI,CAAC;QAC/C,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,UAAU,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC3E,CAAC,CAAC,CAAC;IACH,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE;QACrD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC;YAC7B,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,KAAK;YAC7B,MAAM,EAAE,GAAG;YACX,SAAS,EAAE,WAAW,CAAC,GAAG,EAAE;YAC5B,IAAI,EAAE,IAAI;YACV,SAAS,EAAE,UAAU,CAAC,SAAS,CAAC,SAAS,EAAE,IAAI,MAAM;SACtD,CAAC,CAAC;QACH,IAAI,KAAK,EAAE,MAAM;YAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACrD,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/nuxt.d.ts
ADDED
package/dist/nuxt.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// `modules: ["@tracepanel/js/nuxt"]` in nuxt.config: a Nitro plugin that names each request by its route, puts the badge
|
|
2
|
+
// on server-rendered pages and the trace's id on every response. OpenTelemetry itself goes in before Nitro, with
|
|
3
|
+
// `NODE_OPTIONS=--import=@tracepanel/js/register nuxt dev`, so it can hook the database driver as Nitro loads it.
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
export default function tracepanelModule(_options, nuxt) {
|
|
6
|
+
if (!nuxt.options.dev)
|
|
7
|
+
return;
|
|
8
|
+
nuxt.options.nitro.plugins = [
|
|
9
|
+
...(nuxt.options.nitro.plugins ?? []),
|
|
10
|
+
fileURLToPath(new URL("./nuxt-plugin.js", import.meta.url)),
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
Object.assign(tracepanelModule, {
|
|
14
|
+
getMeta: () => Promise.resolve({ name: "tracepanel", configKey: "tracepanel" }),
|
|
15
|
+
});
|
|
16
|
+
//# sourceMappingURL=nuxt.js.map
|
package/dist/nuxt.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nuxt.js","sourceRoot":"","sources":["../src/nuxt.ts"],"names":[],"mappings":"AAAA,yHAAyH;AACzH,iHAAiH;AACjH,kHAAkH;AAElH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAMzC,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,QAAiB,EAAE,IAAU;IACpE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG;QAAE,OAAO;IAC9B,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,GAAG;QAC3B,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC;QACrC,aAAa,CAAC,IAAI,GAAG,CAAC,kBAAkB,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;KAC5D,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,CAAC,gBAAgB,EAAE;IAC9B,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;CAChF,CAAC,CAAC"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type Context } from "@opentelemetry/api";
|
|
2
|
+
import type { ReadableSpan, Span as SdkSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
3
|
+
import type { Config } from "./config.js";
|
|
4
|
+
import { type Log, micros, type Trace } from "./model.js";
|
|
5
|
+
/** Where a trace goes once its request ends. */
|
|
6
|
+
export interface Sink {
|
|
7
|
+
submit(trace: Trace): void;
|
|
8
|
+
}
|
|
9
|
+
/** One request's trace while its spans end. */
|
|
10
|
+
interface Building {
|
|
11
|
+
trace: Trace;
|
|
12
|
+
/** OTel span id: the Tracepanel span id it is, or its nearest kept ancestor's when it is left out. */
|
|
13
|
+
ids: Map<string, string | null>;
|
|
14
|
+
origins: Map<string, string | null>;
|
|
15
|
+
queries: number;
|
|
16
|
+
dbTime: number;
|
|
17
|
+
framework: string;
|
|
18
|
+
/** performance.now() as the request's root span started. */
|
|
19
|
+
startedMs: number;
|
|
20
|
+
/** OTel span id of the request's root span here: its parent, if any, is in another process. */
|
|
21
|
+
rootSpanId: string;
|
|
22
|
+
/** The route pattern a framework named (Nitro's /api/books/:id), for a root span that has none. */
|
|
23
|
+
route: string | null;
|
|
24
|
+
/** The request as its root span started: method and target, to find it from a response sent outside its context. */
|
|
25
|
+
request: string;
|
|
26
|
+
/** Whether a response's headers have taken this trace's id already. */
|
|
27
|
+
answered: boolean;
|
|
28
|
+
/** Tracepanel ids of Nest controller spans: the exceptions they record may be HTTP answers, not errors. */
|
|
29
|
+
nestViews: Set<string>;
|
|
30
|
+
/** OTel span id: the Tracepanel id of the span's parent when it started. */
|
|
31
|
+
parents: Map<string, string | null>;
|
|
32
|
+
/** Express's anonymous route handlers that a Nest controller's span stands in for: left out, children and all. */
|
|
33
|
+
replaced: Set<string>;
|
|
34
|
+
/** OTel span id: the Express route handlers' ones, so a Nest span under one can take its place. */
|
|
35
|
+
expressHandlers: Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* The app's line behind the latest span that had one. A driver often starts its query span later, from a callback
|
|
38
|
+
* (pg's pool runs the query once it has a connection), where the stack holds none of the app's code; the
|
|
39
|
+
* connection checkout before it started right in the app's call.
|
|
40
|
+
*/
|
|
41
|
+
lastOrigin: string | null;
|
|
42
|
+
}
|
|
43
|
+
export declare class TracepanelProcessor implements SpanProcessor {
|
|
44
|
+
private readonly config;
|
|
45
|
+
private readonly sink;
|
|
46
|
+
/** The traces being built, by their root span's OTel id. */
|
|
47
|
+
private readonly open;
|
|
48
|
+
/**
|
|
49
|
+
* OTel span id → the trace it belongs to. Not the OTel trace id: a request the app makes to itself (fetch to its own
|
|
50
|
+
* API) carries the caller's trace id, and is a trace of its own all the same.
|
|
51
|
+
*/
|
|
52
|
+
private readonly owner;
|
|
53
|
+
constructor(config: Config, sink: Sink);
|
|
54
|
+
onStart(span: SdkSpan, _parentContext: Context): void;
|
|
55
|
+
onEnd(span: ReadableSpan): void;
|
|
56
|
+
forceFlush(): Promise<void>;
|
|
57
|
+
shutdown(): Promise<void>;
|
|
58
|
+
/** The trace being built for the active request, with the current span's Tracepanel id. */
|
|
59
|
+
current(): {
|
|
60
|
+
building: Building;
|
|
61
|
+
spanId: string | null;
|
|
62
|
+
} | undefined;
|
|
63
|
+
/** What the widget shows before the request's span ends: its id, its queries so far and their time. */
|
|
64
|
+
timing(request?: {
|
|
65
|
+
method: string;
|
|
66
|
+
url: string;
|
|
67
|
+
}): Timing | undefined;
|
|
68
|
+
private dropAbandoned;
|
|
69
|
+
/** The trace is done here: its spans no longer lead to it. */
|
|
70
|
+
private close;
|
|
71
|
+
/**
|
|
72
|
+
* The open trace of a request whose response goes out outside its context (Next.js streams a page from another
|
|
73
|
+
* async context): the latest one for this method and URL that no response has answered yet.
|
|
74
|
+
*/
|
|
75
|
+
private waiting;
|
|
76
|
+
/** An error a framework caught and answered with a 500 itself, without recording it on a span (Nitro). */
|
|
77
|
+
addError(error: unknown): void;
|
|
78
|
+
addLog(log: Omit<Log, "span_id">): void;
|
|
79
|
+
/** The framework the active request runs on, unless a more specific one (Nest on Express) already said so. */
|
|
80
|
+
setFramework(name: string): void;
|
|
81
|
+
/** The route pattern the framework matched, when the root span doesn't carry one (Nitro). */
|
|
82
|
+
setRoute(route: string): void;
|
|
83
|
+
/** The framework the active request ran on, as the panel shows it. */
|
|
84
|
+
framework(): string | undefined;
|
|
85
|
+
private start;
|
|
86
|
+
/** A span as Tracepanel draws it: the request, a route handler (view), a statement (sql), an outgoing call. */
|
|
87
|
+
private convert;
|
|
88
|
+
private sqlAttrs;
|
|
89
|
+
/** Exceptions the instrumentation recorded on a span: the request's errors, with their frames. */
|
|
90
|
+
private collectErrors;
|
|
91
|
+
/** The trace from its root span: name, timing, the request's attributes. */
|
|
92
|
+
private finish;
|
|
93
|
+
/** A command (trace()) or a task (a queue's job): named as it was started, its task.* attributes kept. */
|
|
94
|
+
private finishWork;
|
|
95
|
+
}
|
|
96
|
+
export interface Timing {
|
|
97
|
+
id: string;
|
|
98
|
+
queries: number;
|
|
99
|
+
dbMs: number;
|
|
100
|
+
/** performance.now() as the request started. */
|
|
101
|
+
startedMs: number;
|
|
102
|
+
}
|
|
103
|
+
/** `Node 26 · Express`: what the panel shows next to its logo. */
|
|
104
|
+
export declare function runtimeLabel(framework: string): string;
|
|
105
|
+
export { micros };
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// The span processor: the app traces itself with OpenTelemetry (its HTTP server, Express, its database driver), and
|
|
2
|
+
// each request's spans become one Tracepanel trace, sent to the agent when the request's span ends.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
5
|
+
import { httpCall, isMongo, isRedis, mongoCall, redisCall } from "./calls.js";
|
|
6
|
+
import { withVersion } from "./frameworks.js";
|
|
7
|
+
import { countDropped, limited, MAX_LOGS, micros, newId, } from "./model.js";
|
|
8
|
+
import { fingerprint, spanName, statement } from "./sql.js";
|
|
9
|
+
import { frames, origin } from "./stack.js";
|
|
10
|
+
// A request's span can end after the response does (Express ends an async handler's span when its promise settles):
|
|
11
|
+
// the trace goes out this long after the request's span ends, with the spans that ended meanwhile.
|
|
12
|
+
const LATE_SPANS_MS = 25;
|
|
13
|
+
// A request whose root span never ends (the client went away mid-stream and the framework didn't end it) is dropped
|
|
14
|
+
// after this long, so the open traces don't grow for ever.
|
|
15
|
+
const ABANDONED_MS = 10 * 60 * 1000;
|
|
16
|
+
// Spans that are bookkeeping, not work: Express's middleware and routers, a driver's connection checkouts, and
|
|
17
|
+
// Nest's inner handler span (the same call as its request context span, which names the controller).
|
|
18
|
+
const SKIPPED_EXPRESS = new Set(["middleware", "router"]);
|
|
19
|
+
const SKIPPED_NEST = new Set(["handler", "app_creation"]);
|
|
20
|
+
// Next.js's own bookkeeping: finding and loading the route's modules, the first byte, metadata.
|
|
21
|
+
const SKIPPED_NEXT = new Set([
|
|
22
|
+
"NextNodeServer.findPageComponents",
|
|
23
|
+
"NextNodeServer.getLayoutOrPageModule",
|
|
24
|
+
"NextNodeServer.startResponse",
|
|
25
|
+
"NextNodeServer.clientComponentLoading",
|
|
26
|
+
"NextNodeServer.createComponentTree",
|
|
27
|
+
"LoadComponents.loadRouteModule",
|
|
28
|
+
"RouteModule.prepare",
|
|
29
|
+
"ResolveMetadata.generateMetadata",
|
|
30
|
+
]);
|
|
31
|
+
// What runs a Next.js route: an app or pages render, a route handler, getServerSideProps.
|
|
32
|
+
const NEXT_HANDLERS = new Set([
|
|
33
|
+
"AppRender.getBodyResult",
|
|
34
|
+
"AppRouteRouteHandlers.runHandler",
|
|
35
|
+
"Render.getServerSideProps",
|
|
36
|
+
"Render.renderDocument",
|
|
37
|
+
]);
|
|
38
|
+
// Next.js's requests for its own assets and dev tooling, not the app's.
|
|
39
|
+
const NEXT_INTERNAL = /^\/(_next|__nextjs)/;
|
|
40
|
+
// Prisma's own spans besides the operation: its SQL goes through the driver adapter's driver (pg, mysql2), recorded
|
|
41
|
+
// there; the rest is its query engine's bookkeeping.
|
|
42
|
+
const SKIPPED_NAMES = /^(pg\.connect|pg-pool\.connect|mysql2?\.connect|prisma:(?!client:operation$))/;
|
|
43
|
+
function attrsOf(span) {
|
|
44
|
+
return { ...span.attributes };
|
|
45
|
+
}
|
|
46
|
+
function first(attrs, ...keys) {
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
if (attrs[key] !== undefined)
|
|
49
|
+
return attrs[key];
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
function durationUs(span) {
|
|
54
|
+
const [seconds, nanos] = span.duration;
|
|
55
|
+
return Math.round(seconds * 1e6 + nanos / 1e3);
|
|
56
|
+
}
|
|
57
|
+
function startUs(span) {
|
|
58
|
+
const [seconds, nanos] = span.startTime;
|
|
59
|
+
return Math.round(seconds * 1e6 + nanos / 1e3);
|
|
60
|
+
}
|
|
61
|
+
function skipped(span) {
|
|
62
|
+
const attrs = attrsOf(span);
|
|
63
|
+
return (SKIPPED_EXPRESS.has(String(attrs["express.type"])) ||
|
|
64
|
+
SKIPPED_NEST.has(String(attrs["nestjs.type"])) ||
|
|
65
|
+
SKIPPED_NEXT.has(String(attrs["next.span_type"])) ||
|
|
66
|
+
SKIPPED_NAMES.test(span.name));
|
|
67
|
+
}
|
|
68
|
+
function statementOf(attrs) {
|
|
69
|
+
const text = first(attrs, "db.query.text", "db.statement");
|
|
70
|
+
return typeof text === "string" ? text : "";
|
|
71
|
+
}
|
|
72
|
+
export class TracepanelProcessor {
|
|
73
|
+
config;
|
|
74
|
+
sink;
|
|
75
|
+
/** The traces being built, by their root span's OTel id. */
|
|
76
|
+
open = new Map();
|
|
77
|
+
/**
|
|
78
|
+
* OTel span id → the trace it belongs to. Not the OTel trace id: a request the app makes to itself (fetch to its own
|
|
79
|
+
* API) carries the caller's trace id, and is a trace of its own all the same.
|
|
80
|
+
*/
|
|
81
|
+
owner = new Map();
|
|
82
|
+
constructor(config, sink) {
|
|
83
|
+
this.config = config;
|
|
84
|
+
this.sink = sink;
|
|
85
|
+
}
|
|
86
|
+
onStart(span, _parentContext) {
|
|
87
|
+
const context = span.spanContext();
|
|
88
|
+
const parent = span.parentSpanContext;
|
|
89
|
+
let building = parent ? this.owner.get(parent.spanId) : undefined;
|
|
90
|
+
// A request that came with a traceparent (from another app, or this one calling itself) starts a trace here.
|
|
91
|
+
const local = !parent || (parent.isRemote === true && span.kind === SpanKind.SERVER);
|
|
92
|
+
if (local) {
|
|
93
|
+
// A request, or work outside one that trace() or a task queue started: tracepanel.kind says which.
|
|
94
|
+
const kind = attrsOf(span)["tracepanel.kind"];
|
|
95
|
+
if (span.kind !== SpanKind.SERVER && kind !== "command" && kind !== "task")
|
|
96
|
+
return;
|
|
97
|
+
if (NEXT_INTERNAL.test(String(first(attrsOf(span), "http.target", "url.path") ?? "")))
|
|
98
|
+
return;
|
|
99
|
+
this.dropAbandoned();
|
|
100
|
+
building = this.start();
|
|
101
|
+
if (kind === "command" || kind === "task")
|
|
102
|
+
building.trace.kind = kind;
|
|
103
|
+
building.rootSpanId = context.spanId;
|
|
104
|
+
const rootAttrs = attrsOf(span);
|
|
105
|
+
building.request = requestKey(first(rootAttrs, "http.request.method", "http.method"), first(rootAttrs, "http.target", "url.path"));
|
|
106
|
+
this.open.set(context.spanId, building);
|
|
107
|
+
}
|
|
108
|
+
if (!building)
|
|
109
|
+
return;
|
|
110
|
+
this.owner.set(context.spanId, building);
|
|
111
|
+
const found = origin(this.config.root);
|
|
112
|
+
if (found)
|
|
113
|
+
building.lastOrigin = found;
|
|
114
|
+
if (!local && parent && skipped(span)) {
|
|
115
|
+
building.ids.set(context.spanId, building.ids.get(parent.spanId) ?? null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const attrs = attrsOf(span);
|
|
119
|
+
if (attrs["express.type"] === "request_handler")
|
|
120
|
+
building.expressHandlers.add(context.spanId);
|
|
121
|
+
if (parent && attrs["nestjs.type"] === "request_context" && building.expressHandlers.has(parent.spanId)) {
|
|
122
|
+
// Nest's router is Express's route handler: the controller's span takes its place in the tree.
|
|
123
|
+
building.replaced.add(parent.spanId);
|
|
124
|
+
building.ids.set(parent.spanId, building.parents.get(parent.spanId) ?? null);
|
|
125
|
+
building.framework = "NestJS";
|
|
126
|
+
}
|
|
127
|
+
building.parents.set(context.spanId, parent && !local ? (building.ids.get(parent.spanId) ?? null) : null);
|
|
128
|
+
if (!parent && attrs["next.span_type"] === "BaseServer.handleRequest")
|
|
129
|
+
building.framework = "Next.js";
|
|
130
|
+
building.ids.set(context.spanId, newId());
|
|
131
|
+
building.origins.set(context.spanId, found ?? building.lastOrigin);
|
|
132
|
+
}
|
|
133
|
+
onEnd(span) {
|
|
134
|
+
if (process.env.TRACEPANEL_DEBUG) {
|
|
135
|
+
process.stderr.write(`[tracepanel] ${span.name} kind=${span.kind} parent=${span.parentSpanContext?.spanId ?? "-"} ${JSON.stringify(span.attributes)}\n`);
|
|
136
|
+
}
|
|
137
|
+
const context = span.spanContext();
|
|
138
|
+
const building = this.owner.get(context.spanId);
|
|
139
|
+
if (!building)
|
|
140
|
+
return;
|
|
141
|
+
const root = context.spanId === building.rootSpanId;
|
|
142
|
+
if (building.replaced.has(context.spanId))
|
|
143
|
+
return;
|
|
144
|
+
if (root || !skipped(span))
|
|
145
|
+
this.convert(building, span, root);
|
|
146
|
+
if (!root)
|
|
147
|
+
return;
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
this.close(building);
|
|
150
|
+
this.finish(building, span);
|
|
151
|
+
this.sink.submit(limited(building.trace));
|
|
152
|
+
}, LATE_SPANS_MS).unref();
|
|
153
|
+
}
|
|
154
|
+
forceFlush() {
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
157
|
+
shutdown() {
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
}
|
|
160
|
+
/** The trace being built for the active request, with the current span's Tracepanel id. */
|
|
161
|
+
current() {
|
|
162
|
+
const span = trace.getActiveSpan();
|
|
163
|
+
if (!span)
|
|
164
|
+
return undefined;
|
|
165
|
+
const context = span.spanContext();
|
|
166
|
+
const building = this.owner.get(context.spanId);
|
|
167
|
+
return building ? { building, spanId: building.ids.get(context.spanId) ?? null } : undefined;
|
|
168
|
+
}
|
|
169
|
+
/** What the widget shows before the request's span ends: its id, its queries so far and their time. */
|
|
170
|
+
timing(request) {
|
|
171
|
+
const building = this.current()?.building ?? (request ? this.waiting(request) : undefined);
|
|
172
|
+
if (!building)
|
|
173
|
+
return undefined;
|
|
174
|
+
// A response's headers took it: another response to the same URL is another request's.
|
|
175
|
+
if (request)
|
|
176
|
+
building.answered = true;
|
|
177
|
+
return {
|
|
178
|
+
id: building.trace.id,
|
|
179
|
+
queries: building.queries,
|
|
180
|
+
dbMs: building.dbTime / 1000,
|
|
181
|
+
startedMs: building.startedMs,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
dropAbandoned() {
|
|
185
|
+
const oldest = performance.now() - ABANDONED_MS;
|
|
186
|
+
for (const building of [...this.open.values()]) {
|
|
187
|
+
if (building.startedMs < oldest)
|
|
188
|
+
this.close(building);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** The trace is done here: its spans no longer lead to it. */
|
|
192
|
+
close(building) {
|
|
193
|
+
this.open.delete(building.rootSpanId);
|
|
194
|
+
for (const spanId of building.ids.keys())
|
|
195
|
+
this.owner.delete(spanId);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The open trace of a request whose response goes out outside its context (Next.js streams a page from another
|
|
199
|
+
* async context): the latest one for this method and URL that no response has answered yet.
|
|
200
|
+
*/
|
|
201
|
+
waiting(request) {
|
|
202
|
+
const key = requestKey(request.method, request.url);
|
|
203
|
+
let found;
|
|
204
|
+
for (const building of this.open.values()) {
|
|
205
|
+
if (building.request === key && !building.answered)
|
|
206
|
+
found = building;
|
|
207
|
+
}
|
|
208
|
+
return found;
|
|
209
|
+
}
|
|
210
|
+
/** An error a framework caught and answered with a 500 itself, without recording it on a span (Nitro). */
|
|
211
|
+
addError(error) {
|
|
212
|
+
const current = this.current();
|
|
213
|
+
if (!current)
|
|
214
|
+
return;
|
|
215
|
+
const thrown = error instanceof Error ? error : new Error(String(error));
|
|
216
|
+
// h3 wraps what the handler threw: the cause is the app's error.
|
|
217
|
+
const cause = thrown.cause instanceof Error ? thrown.cause : thrown;
|
|
218
|
+
current.building.trace.errors.push({
|
|
219
|
+
span_id: current.spanId,
|
|
220
|
+
time: Date.now() * 1000,
|
|
221
|
+
type: cause.name || "Error",
|
|
222
|
+
message: cause.message,
|
|
223
|
+
handled: false,
|
|
224
|
+
frames: frames(cause.stack ?? "", this.config.root),
|
|
225
|
+
chain: [],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
addLog(log) {
|
|
229
|
+
const current = this.current();
|
|
230
|
+
if (!current)
|
|
231
|
+
return;
|
|
232
|
+
const { trace } = current.building;
|
|
233
|
+
if (trace.logs.length >= MAX_LOGS)
|
|
234
|
+
countDropped(trace, "tracepanel.dropped_logs");
|
|
235
|
+
else
|
|
236
|
+
trace.logs.push({ ...log, span_id: current.spanId });
|
|
237
|
+
}
|
|
238
|
+
/** The framework the active request runs on, unless a more specific one (Nest on Express) already said so. */
|
|
239
|
+
setFramework(name) {
|
|
240
|
+
const current = this.current();
|
|
241
|
+
if (current && current.building.framework === "node:http")
|
|
242
|
+
current.building.framework = name;
|
|
243
|
+
}
|
|
244
|
+
/** The route pattern the framework matched, when the root span doesn't carry one (Nitro). */
|
|
245
|
+
setRoute(route) {
|
|
246
|
+
const current = this.current();
|
|
247
|
+
if (current)
|
|
248
|
+
current.building.route = route;
|
|
249
|
+
}
|
|
250
|
+
/** The framework the active request ran on, as the panel shows it. */
|
|
251
|
+
framework() {
|
|
252
|
+
return this.current()?.building.framework;
|
|
253
|
+
}
|
|
254
|
+
start() {
|
|
255
|
+
return {
|
|
256
|
+
trace: {
|
|
257
|
+
id: newId(),
|
|
258
|
+
kind: "http",
|
|
259
|
+
name: "",
|
|
260
|
+
start: 0,
|
|
261
|
+
duration: 0,
|
|
262
|
+
status: "ok",
|
|
263
|
+
attrs: {},
|
|
264
|
+
spans: [],
|
|
265
|
+
logs: [],
|
|
266
|
+
errors: [],
|
|
267
|
+
links: [],
|
|
268
|
+
issues: [],
|
|
269
|
+
},
|
|
270
|
+
ids: new Map(),
|
|
271
|
+
origins: new Map(),
|
|
272
|
+
queries: 0,
|
|
273
|
+
dbTime: 0,
|
|
274
|
+
framework: "node:http",
|
|
275
|
+
startedMs: performance.now(),
|
|
276
|
+
rootSpanId: "",
|
|
277
|
+
route: null,
|
|
278
|
+
request: "",
|
|
279
|
+
answered: false,
|
|
280
|
+
replaced: new Set(),
|
|
281
|
+
nestViews: new Set(),
|
|
282
|
+
expressHandlers: new Set(),
|
|
283
|
+
parents: new Map(),
|
|
284
|
+
lastOrigin: null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** A span as Tracepanel draws it: the request, a route handler (view), a statement (sql), an outgoing call. */
|
|
288
|
+
convert(building, span, root) {
|
|
289
|
+
const context = span.spanContext();
|
|
290
|
+
const id = building.ids.get(context.spanId);
|
|
291
|
+
if (!id)
|
|
292
|
+
return;
|
|
293
|
+
const parentId = root ? null : (building.ids.get(span.parentSpanContext?.spanId ?? "") ?? null);
|
|
294
|
+
if (!root && parentId === null)
|
|
295
|
+
return;
|
|
296
|
+
const attrs = attrsOf(span);
|
|
297
|
+
const converted = {
|
|
298
|
+
id,
|
|
299
|
+
parent_id: parentId,
|
|
300
|
+
kind: "function",
|
|
301
|
+
name: span.name,
|
|
302
|
+
start: startUs(span),
|
|
303
|
+
duration: durationUs(span),
|
|
304
|
+
status: span.status.code === SpanStatusCode.ERROR ? "error" : "ok",
|
|
305
|
+
attrs: {},
|
|
306
|
+
};
|
|
307
|
+
const codeOrigin = building.origins.get(context.spanId) ?? null;
|
|
308
|
+
const sql = isRedis(attrs) || isMongo(attrs) ? "" : statementOf(attrs);
|
|
309
|
+
if (root) {
|
|
310
|
+
converted.kind = { http: "http", task: "task.run", command: "command" }[building.trace.kind];
|
|
311
|
+
}
|
|
312
|
+
else if (attrs["express.type"] === "request_handler") {
|
|
313
|
+
converted.kind = "view";
|
|
314
|
+
converted.name = String(attrs["express.name"] ?? span.name);
|
|
315
|
+
building.trace.attrs["code.view"] = converted.name;
|
|
316
|
+
}
|
|
317
|
+
else if (NEXT_HANDLERS.has(String(attrs["next.span_type"]))) {
|
|
318
|
+
converted.kind = "view";
|
|
319
|
+
converted.name = String(first(attrs, "next.route", "next.page") ?? span.name);
|
|
320
|
+
building.trace.attrs["code.view"] = converted.name;
|
|
321
|
+
}
|
|
322
|
+
else if (attrs["nestjs.type"] === "request_context") {
|
|
323
|
+
converted.kind = "view";
|
|
324
|
+
converted.name = `${attrs["nestjs.controller"] ?? "Controller"}.${attrs["nestjs.callback"] ?? span.name}`;
|
|
325
|
+
building.trace.attrs["code.view"] = converted.name;
|
|
326
|
+
building.nestViews.add(id);
|
|
327
|
+
}
|
|
328
|
+
else if (attrs["tracepanel.span_kind"] === "task.enqueue") {
|
|
329
|
+
converted.kind = "task.enqueue";
|
|
330
|
+
converted.name = String(attrs["task.name"] ?? span.name);
|
|
331
|
+
converted.attrs = {
|
|
332
|
+
...Object.fromEntries(Object.entries(attrs).filter(([key]) => key.startsWith("task."))),
|
|
333
|
+
"code.origin": codeOrigin,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
else if (isRedis(attrs)) {
|
|
337
|
+
const call = redisCall(attrs, span.name);
|
|
338
|
+
converted.kind = "redis";
|
|
339
|
+
converted.name = call.name;
|
|
340
|
+
converted.attrs = { ...call.attrs, "code.origin": codeOrigin };
|
|
341
|
+
}
|
|
342
|
+
else if (isMongo(attrs)) {
|
|
343
|
+
const call = mongoCall(attrs, span.name);
|
|
344
|
+
converted.kind = "mongodb";
|
|
345
|
+
converted.name = call.name;
|
|
346
|
+
converted.attrs = { ...call.attrs, "code.origin": codeOrigin };
|
|
347
|
+
}
|
|
348
|
+
else if (span.name === "prisma:client:operation") {
|
|
349
|
+
converted.name = `prisma ${attrs.model ? `${attrs.model}.` : ""}${attrs.method ?? "operation"}`;
|
|
350
|
+
converted.attrs = { "code.origin": codeOrigin };
|
|
351
|
+
}
|
|
352
|
+
else if (sql) {
|
|
353
|
+
converted.kind = "sql";
|
|
354
|
+
converted.name = spanName(sql);
|
|
355
|
+
converted.attrs = this.sqlAttrs(attrs, sql, codeOrigin);
|
|
356
|
+
building.queries += 1;
|
|
357
|
+
building.dbTime += converted.duration;
|
|
358
|
+
}
|
|
359
|
+
else if (span.kind === SpanKind.CLIENT && first(attrs, "http.request.method", "http.method")) {
|
|
360
|
+
const call = httpCall(attrs);
|
|
361
|
+
converted.kind = "http.client";
|
|
362
|
+
converted.name = call.name;
|
|
363
|
+
// The traceparent the call carried names this span: the agent links the callee's trace to it.
|
|
364
|
+
converted.attrs = { ...call.attrs, "w3c.span_id": context.spanId, "code.origin": codeOrigin };
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
converted.attrs = { ...attrs, "code.origin": codeOrigin };
|
|
368
|
+
}
|
|
369
|
+
building.trace.spans.push(converted);
|
|
370
|
+
this.collectErrors(building, span, id);
|
|
371
|
+
}
|
|
372
|
+
sqlAttrs(attrs, sql, codeOrigin) {
|
|
373
|
+
const system = String(first(attrs, "db.system.name", "db.system") ?? "");
|
|
374
|
+
// pg reports the parameters with enhancedDatabaseReporting: the same statement with the same values is a
|
|
375
|
+
// duplicate, the same statement with other values an N+1 candidate.
|
|
376
|
+
const values = first(attrs, "db.postgresql.values", "db.query.parameter");
|
|
377
|
+
// What EXPLAIN binds back: pg's values; mysql2 writes them into the statement itself.
|
|
378
|
+
const params = Array.isArray(values) ? values : system === "mysql" ? [] : undefined;
|
|
379
|
+
return {
|
|
380
|
+
...(params ? { "db.params": params } : {}),
|
|
381
|
+
"db.client": system === "postgresql" ? "pg" : system,
|
|
382
|
+
"db.system": system,
|
|
383
|
+
"db.vendor": system,
|
|
384
|
+
"db.alias": "default",
|
|
385
|
+
"db.statement": statement(sql),
|
|
386
|
+
"db.fingerprint": fingerprint(sql),
|
|
387
|
+
"db.params_hash": values === undefined ? null : createHash("sha1").update(JSON.stringify(values)).digest("hex").slice(0, 12),
|
|
388
|
+
"db.many": false,
|
|
389
|
+
"code.origin": codeOrigin,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** Exceptions the instrumentation recorded on a span: the request's errors, with their frames. */
|
|
393
|
+
collectErrors(building, span, spanId) {
|
|
394
|
+
for (const event of span.events) {
|
|
395
|
+
if (event.name !== "exception")
|
|
396
|
+
continue;
|
|
397
|
+
const attrs = event.attributes ?? {};
|
|
398
|
+
const [seconds, nanos] = event.time;
|
|
399
|
+
building.trace.errors.push({
|
|
400
|
+
span_id: spanId,
|
|
401
|
+
time: Math.round(seconds * 1e6 + nanos / 1e3),
|
|
402
|
+
type: String(attrs["exception.type"] ?? "Error"),
|
|
403
|
+
message: String(attrs["exception.message"] ?? ""),
|
|
404
|
+
handled: false,
|
|
405
|
+
frames: frames(String(attrs["exception.stacktrace"] ?? ""), this.config.root),
|
|
406
|
+
chain: [],
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** The trace from its root span: name, timing, the request's attributes. */
|
|
411
|
+
finish(building, root) {
|
|
412
|
+
if (building.trace.kind !== "http") {
|
|
413
|
+
this.finishWork(building, root);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const attrs = attrsOf(root);
|
|
417
|
+
const method = String(first(attrs, "http.request.method", "http.method") ?? "GET");
|
|
418
|
+
const path = String(first(attrs, "url.path", "http.target") ?? "/").split("?")[0];
|
|
419
|
+
const route = first(attrs, "http.route", "next.route") ?? building.route;
|
|
420
|
+
const status = Number(first(attrs, "http.response.status_code", "http.status_code") ?? 0);
|
|
421
|
+
const tr = building.trace;
|
|
422
|
+
if (status > 0 && status < 500) {
|
|
423
|
+
// Nest throws NotFoundException and the like to answer 404, 422…: an answer, not an error.
|
|
424
|
+
tr.errors = tr.errors.filter((error) => !building.nestViews.has(error.span_id ?? ""));
|
|
425
|
+
}
|
|
426
|
+
tr.name = `${method} ${typeof route === "string" && route ? route : path}`;
|
|
427
|
+
tr.start = startUs(root);
|
|
428
|
+
tr.duration = durationUs(root);
|
|
429
|
+
if (status >= 500 || root.status.code === SpanStatusCode.ERROR || tr.errors.length > 0)
|
|
430
|
+
tr.status = "error";
|
|
431
|
+
const rootSpan = tr.spans.find((span) => span.parent_id === null);
|
|
432
|
+
if (rootSpan)
|
|
433
|
+
rootSpan.name = tr.name;
|
|
434
|
+
tr.attrs = {
|
|
435
|
+
...tr.attrs,
|
|
436
|
+
"http.method": method,
|
|
437
|
+
"http.path": path,
|
|
438
|
+
"http.route": typeof route === "string" ? route : null,
|
|
439
|
+
"http.status_code": status,
|
|
440
|
+
"service.name": this.config.service,
|
|
441
|
+
"service.role": "web",
|
|
442
|
+
"telemetry.sdk.language": "nodejs",
|
|
443
|
+
"process.runtime.name": process.versions.bun ? "bun" : "node",
|
|
444
|
+
"process.runtime.version": process.versions.bun ?? process.versions.node,
|
|
445
|
+
"tracepanel.framework": withVersion(building.framework),
|
|
446
|
+
// The W3C ids: the agent links this trace to the one that called it, in another app or process.
|
|
447
|
+
"w3c.trace_id": root.spanContext().traceId,
|
|
448
|
+
};
|
|
449
|
+
if (root.parentSpanContext?.isRemote)
|
|
450
|
+
tr.attrs["w3c.parent_span_id"] = root.parentSpanContext.spanId;
|
|
451
|
+
}
|
|
452
|
+
/** A command (trace()) or a task (a queue's job): named as it was started, its task.* attributes kept. */
|
|
453
|
+
finishWork(building, root) {
|
|
454
|
+
const attrs = attrsOf(root);
|
|
455
|
+
const tr = building.trace;
|
|
456
|
+
tr.name = String(attrs["tracepanel.name"] ?? root.name);
|
|
457
|
+
tr.start = startUs(root);
|
|
458
|
+
tr.duration = durationUs(root);
|
|
459
|
+
if (root.status.code === SpanStatusCode.ERROR || tr.errors.length > 0)
|
|
460
|
+
tr.status = "error";
|
|
461
|
+
const rootSpan = tr.spans.find((span) => span.parent_id === null);
|
|
462
|
+
if (rootSpan) {
|
|
463
|
+
rootSpan.name = tr.name;
|
|
464
|
+
rootSpan.attrs = {};
|
|
465
|
+
}
|
|
466
|
+
const own = Object.fromEntries(Object.entries(attrs).filter(([key]) => key.startsWith("task.")));
|
|
467
|
+
tr.attrs = {
|
|
468
|
+
...tr.attrs,
|
|
469
|
+
...own,
|
|
470
|
+
"service.name": this.config.service,
|
|
471
|
+
"service.role": tr.kind === "task" ? "worker" : "command",
|
|
472
|
+
"telemetry.sdk.language": "nodejs",
|
|
473
|
+
"process.runtime.name": process.versions.bun ? "bun" : "node",
|
|
474
|
+
"process.runtime.version": process.versions.bun ?? process.versions.node,
|
|
475
|
+
"w3c.trace_id": root.spanContext().traceId,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function requestKey(method, target) {
|
|
480
|
+
return `${String(method ?? "GET").toUpperCase()} ${String(target ?? "/")}`;
|
|
481
|
+
}
|
|
482
|
+
/** `Node 26 · Express`: what the panel shows next to its logo. */
|
|
483
|
+
export function runtimeLabel(framework) {
|
|
484
|
+
const bun = process.versions.bun;
|
|
485
|
+
const named = withVersion(framework, 2);
|
|
486
|
+
if (bun)
|
|
487
|
+
return `Bun ${bun.split(".").slice(0, 2).join(".")} · ${named}`;
|
|
488
|
+
return `Node ${process.versions.node.split(".")[0]} · ${named}`;
|
|
489
|
+
}
|
|
490
|
+
export { micros };
|
|
491
|
+
//# sourceMappingURL=processor.js.map
|