@neat.is/core 0.3.6 → 0.3.7
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/dist/{chunk-4ASCXBZF.js → chunk-75IBA4UL.js} +34 -5
- package/dist/chunk-75IBA4UL.js.map +1 -0
- package/dist/{chunk-G3PDTGOW.js → chunk-CY67YKNO.js} +2 -2
- package/dist/{chunk-YHQYHFI3.js → chunk-EDHOCFOG.js} +86 -14
- package/dist/chunk-EDHOCFOG.js.map +1 -0
- package/dist/cli.cjs +93 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +62 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +117 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +3 -3
- package/dist/neatd.cjs +117 -16
- package/dist/neatd.cjs.map +1 -1
- package/dist/neatd.js +2 -2
- package/dist/{otel-grpc-J4O2SIBZ.js → otel-grpc-PM4SWPZE.js} +3 -3
- package/dist/server.cjs +33 -4
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +2 -2
- package/package.json +2 -2
- package/dist/chunk-4ASCXBZF.js.map +0 -1
- package/dist/chunk-YHQYHFI3.js.map +0 -1
- /package/dist/{chunk-G3PDTGOW.js.map → chunk-CY67YKNO.js.map} +0 -0
- /package/dist/{otel-grpc-J4O2SIBZ.js.map → otel-grpc-PM4SWPZE.js.map} +0 -0
|
@@ -98,8 +98,8 @@ function parseOtlpRequest(body) {
|
|
|
98
98
|
return out;
|
|
99
99
|
}
|
|
100
100
|
var exportTraceServiceRequestType = null;
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
var exportTraceServiceResponseType = null;
|
|
102
|
+
function loadProtoRoot() {
|
|
103
103
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
104
104
|
const protoRoot = path.resolve(here, "..", "proto");
|
|
105
105
|
const root = new protobuf.Root();
|
|
@@ -108,15 +108,37 @@ function loadProtobufDecoder() {
|
|
|
108
108
|
"opentelemetry/proto/collector/trace/v1/trace_service.proto",
|
|
109
109
|
{ keepCase: true }
|
|
110
110
|
);
|
|
111
|
+
return root;
|
|
112
|
+
}
|
|
113
|
+
function loadProtobufDecoder() {
|
|
114
|
+
if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
|
|
115
|
+
const root = loadProtoRoot();
|
|
111
116
|
exportTraceServiceRequestType = root.lookupType(
|
|
112
117
|
"opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"
|
|
113
118
|
);
|
|
114
119
|
return exportTraceServiceRequestType;
|
|
115
120
|
}
|
|
121
|
+
function loadProtobufResponseEncoder() {
|
|
122
|
+
if (exportTraceServiceResponseType) return exportTraceServiceResponseType;
|
|
123
|
+
const root = loadProtoRoot();
|
|
124
|
+
exportTraceServiceResponseType = root.lookupType(
|
|
125
|
+
"opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse"
|
|
126
|
+
);
|
|
127
|
+
return exportTraceServiceResponseType;
|
|
128
|
+
}
|
|
129
|
+
var cachedProtobufResponseBody = null;
|
|
130
|
+
function encodeProtobufResponseBody() {
|
|
131
|
+
if (cachedProtobufResponseBody) return cachedProtobufResponseBody;
|
|
132
|
+
const Type = loadProtobufResponseEncoder();
|
|
133
|
+
const msg = Type.create({});
|
|
134
|
+
const encoded = Type.encode(msg).finish();
|
|
135
|
+
cachedProtobufResponseBody = Buffer.from(encoded);
|
|
136
|
+
return cachedProtobufResponseBody;
|
|
137
|
+
}
|
|
116
138
|
async function decodeProtobufBody(buf) {
|
|
117
139
|
const Type = loadProtobufDecoder();
|
|
118
140
|
const decoded = Type.decode(buf).toJSON();
|
|
119
|
-
const { reshapeGrpcRequest } = await import("./otel-grpc-
|
|
141
|
+
const { reshapeGrpcRequest } = await import("./otel-grpc-PM4SWPZE.js");
|
|
120
142
|
return reshapeGrpcRequest(decoded);
|
|
121
143
|
}
|
|
122
144
|
async function buildOtelReceiver(opts) {
|
|
@@ -159,7 +181,9 @@ async function buildOtelReceiver(opts) {
|
|
|
159
181
|
app.post("/v1/traces", async (req, reply) => {
|
|
160
182
|
const ct = (req.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
|
|
161
183
|
let body;
|
|
184
|
+
let responseFlavor;
|
|
162
185
|
if (ct === "application/x-protobuf") {
|
|
186
|
+
responseFlavor = "protobuf";
|
|
163
187
|
try {
|
|
164
188
|
body = await decodeProtobufBody(req.body);
|
|
165
189
|
} catch (err) {
|
|
@@ -168,6 +192,7 @@ async function buildOtelReceiver(opts) {
|
|
|
168
192
|
});
|
|
169
193
|
}
|
|
170
194
|
} else if (!ct || ct === "application/json") {
|
|
195
|
+
responseFlavor = "json";
|
|
171
196
|
body = req.body ?? {};
|
|
172
197
|
} else {
|
|
173
198
|
return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
|
|
@@ -185,7 +210,11 @@ async function buildOtelReceiver(opts) {
|
|
|
185
210
|
}
|
|
186
211
|
}
|
|
187
212
|
enqueue(spans);
|
|
188
|
-
|
|
213
|
+
if (responseFlavor === "protobuf") {
|
|
214
|
+
const buf = encodeProtobufResponseBody();
|
|
215
|
+
return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
|
|
216
|
+
}
|
|
217
|
+
return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
|
|
189
218
|
});
|
|
190
219
|
const decorated = app;
|
|
191
220
|
decorated.flushPending = async () => {
|
|
@@ -210,4 +239,4 @@ export {
|
|
|
210
239
|
buildOtelReceiver,
|
|
211
240
|
logSpanHandler
|
|
212
241
|
};
|
|
213
|
-
//# sourceMappingURL=chunk-
|
|
242
|
+
//# sourceMappingURL=chunk-75IBA4UL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/otel.ts"],"sourcesContent":["import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport Fastify, { type FastifyInstance } from 'fastify'\nimport protobuf from 'protobufjs'\n\n// OTLP/HTTP receiver. Listens on /v1/traces and decodes the JSON wire format\n// (collector's `otlphttp` exporter with `encoding: json`). Each span is\n// flattened into a ParsedSpan and handed to the configured handler. The\n// handler is the seam #8 wires its edge mapper into; #7 itself stays decoupled\n// from graph mutation.\n\nexport interface ParsedSpan {\n service: string\n traceId: string\n spanId: string\n parentSpanId?: string\n name: string\n kind?: number\n startTimeUnixNano: string\n endTimeUnixNano: string\n // ISO8601 derived from startTimeUnixNano. Production paths (lastObserved on\n // OBSERVED edges) read this so the recorded time reflects when the span fired,\n // not when the receiver received it. Undefined only when startTimeUnixNano is\n // missing or unparseable — handler falls back to wall-clock in that case.\n // See docs/contracts/otel-ingest.md §lastObserved-from-span-time.\n startTimeIso?: string\n // bigint so the 9-digit-nanos arithmetic doesn't lose precision on long traces.\n durationNanos: bigint\n attributes: Record<string, AttributeValue>\n // Convenience accessors for the attributes #8 cares about.\n dbSystem?: string\n dbName?: string\n // 0 = UNSET, 1 = OK, 2 = ERROR per OTLP. We only care that 2 means error.\n statusCode?: number\n errorMessage?: string\n // Pre-extracted from a span event with name=\"exception\". OTLP SDKs record\n // exceptions this way (richer than status.message). handleSpan reads these\n // first, falling back to status.message and span.name. See\n // docs/contracts/otel-ingest.md §exception-data-from-span-events.\n exception?: {\n type?: string\n message?: string\n stacktrace?: string\n }\n}\n\nexport type AttributeValue =\n | string\n | number\n | boolean\n | bigint\n | string[]\n | number[]\n | boolean[]\n | null\n\nexport type SpanHandler = (span: ParsedSpan) => void | Promise<void>\n\nexport interface BuildOtelReceiverOptions {\n onSpan: SpanHandler\n // Synchronous handler for spans with statusCode === 2. The receiver awaits\n // it before replying, so a write failure can return 500 → OTel SDK retries.\n // Optional — wiring is expected to plumb appendErrorEvent here when error\n // durability matters; ad-hoc receivers leave it undefined.\n // See docs/contracts/otel-ingest.md §Error events.\n onErrorSpanSync?: (span: ParsedSpan) => Promise<void>\n // Fastify body limit. OTLP batches can be large; default is 16 MB.\n bodyLimit?: number\n}\n\ninterface OtlpKeyValue {\n key: string\n value?: OtlpAnyValue\n}\n\ninterface OtlpAnyValue {\n stringValue?: string\n intValue?: string | number\n doubleValue?: number\n boolValue?: boolean\n arrayValue?: { values?: OtlpAnyValue[] }\n // kvlistValue / bytesValue are skipped — neither is on the demo path.\n}\n\ninterface OtlpStatus {\n code?: number\n message?: string\n}\n\ninterface OtlpEvent {\n name?: string\n timeUnixNano?: string\n attributes?: OtlpKeyValue[]\n}\n\ninterface OtlpSpan {\n traceId?: string\n spanId?: string\n parentSpanId?: string\n name?: string\n kind?: number\n startTimeUnixNano?: string\n endTimeUnixNano?: string\n attributes?: OtlpKeyValue[]\n events?: OtlpEvent[]\n status?: OtlpStatus\n}\n\nfunction extractExceptionFromEvents(events: OtlpEvent[] | undefined): ParsedSpan['exception'] {\n if (!events) return undefined\n for (const ev of events) {\n if (ev.name !== 'exception') continue\n const attrs = attrsToRecord(ev.attributes)\n const out: ParsedSpan['exception'] = {}\n const t = attrs['exception.type']\n const m = attrs['exception.message']\n const s = attrs['exception.stacktrace']\n if (typeof t === 'string') out.type = t\n if (typeof m === 'string') out.message = m\n if (typeof s === 'string') out.stacktrace = s\n if (out.type || out.message || out.stacktrace) return out\n }\n return undefined\n}\n\ninterface OtlpScopeSpans {\n spans?: OtlpSpan[]\n}\n\ninterface OtlpResourceSpans {\n resource?: { attributes?: OtlpKeyValue[] }\n scopeSpans?: OtlpScopeSpans[]\n}\n\nexport interface OtlpTracesRequest {\n resourceSpans?: OtlpResourceSpans[]\n}\n\nfunction flattenAttribute(v: OtlpAnyValue | undefined): AttributeValue {\n if (!v) return null\n if (v.stringValue !== undefined) return v.stringValue\n if (v.boolValue !== undefined) return v.boolValue\n if (v.intValue !== undefined) {\n return typeof v.intValue === 'string' ? Number(v.intValue) : v.intValue\n }\n if (v.doubleValue !== undefined) return v.doubleValue\n if (v.arrayValue?.values) {\n return v.arrayValue.values.map((x) => flattenAttribute(x)) as AttributeValue\n }\n return null\n}\n\nfunction attrsToRecord(attrs: OtlpKeyValue[] | undefined): Record<string, AttributeValue> {\n const out: Record<string, AttributeValue> = {}\n if (!attrs) return out\n for (const kv of attrs) {\n if (kv.key) out[kv.key] = flattenAttribute(kv.value)\n }\n return out\n}\n\nfunction durationNanos(start?: string, end?: string): bigint {\n if (!start || !end) return 0n\n try {\n return BigInt(end) - BigInt(start)\n } catch {\n return 0n\n }\n}\n\n// Convert OTLP's startTimeUnixNano (a base-10 string of nanoseconds since the\n// Unix epoch) to ISO8601. Returns undefined when the input is missing, zero,\n// or unparseable, so the caller can fall back to wall-clock without surfacing\n// a fake timestamp on the edge.\nexport function isoFromUnixNano(nanos: string | undefined): string | undefined {\n if (!nanos || nanos === '0') return undefined\n try {\n const ms = Number(BigInt(nanos) / 1_000_000n)\n if (!Number.isFinite(ms)) return undefined\n return new Date(ms).toISOString()\n } catch {\n return undefined\n }\n}\n\nexport function parseOtlpRequest(body: OtlpTracesRequest): ParsedSpan[] {\n const out: ParsedSpan[] = []\n for (const rs of body.resourceSpans ?? []) {\n const resourceAttrs = attrsToRecord(rs.resource?.attributes)\n const service = typeof resourceAttrs['service.name'] === 'string'\n ? (resourceAttrs['service.name'] as string)\n : 'unknown'\n\n for (const ss of rs.scopeSpans ?? []) {\n for (const span of ss.spans ?? []) {\n const attrs = attrsToRecord(span.attributes)\n const parsed: ParsedSpan = {\n service,\n traceId: span.traceId ?? '',\n spanId: span.spanId ?? '',\n parentSpanId: span.parentSpanId || undefined,\n name: span.name ?? '',\n kind: span.kind,\n startTimeUnixNano: span.startTimeUnixNano ?? '0',\n endTimeUnixNano: span.endTimeUnixNano ?? '0',\n startTimeIso: isoFromUnixNano(span.startTimeUnixNano),\n durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),\n attributes: attrs,\n dbSystem: typeof attrs['db.system'] === 'string' ? (attrs['db.system'] as string) : undefined,\n dbName: typeof attrs['db.name'] === 'string' ? (attrs['db.name'] as string) : undefined,\n statusCode: span.status?.code,\n errorMessage: span.status?.message,\n exception: extractExceptionFromEvents(span.events),\n }\n out.push(parsed)\n }\n }\n }\n return out\n}\n\nexport interface OtelReceiver {\n app: FastifyInstance\n // Resolves once every span enqueued so far has been handed to opts.onSpan.\n // Test seam — production code never awaits this.\n flushPending: () => Promise<void>\n}\n\n// Lazy-loaded protobuf decoder for ExportTraceServiceRequest. The bundled\n// .proto tree at packages/core/proto/ is shared with the gRPC receiver\n// (ADR-020). Cached after first load so successive receiver builds reuse it.\nlet exportTraceServiceRequestType: protobuf.Type | null = null\nlet exportTraceServiceResponseType: protobuf.Type | null = null\n\nfunction loadProtoRoot(): protobuf.Root {\n const here = path.dirname(fileURLToPath(import.meta.url))\n const protoRoot = path.resolve(here, '..', 'proto')\n const root = new protobuf.Root()\n root.resolvePath = (_origin, target) => path.resolve(protoRoot, target)\n root.loadSync(\n 'opentelemetry/proto/collector/trace/v1/trace_service.proto',\n { keepCase: true },\n )\n return root\n}\n\nfunction loadProtobufDecoder(): protobuf.Type {\n if (exportTraceServiceRequestType) return exportTraceServiceRequestType\n const root = loadProtoRoot()\n exportTraceServiceRequestType = root.lookupType(\n 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest',\n )\n return exportTraceServiceRequestType\n}\n\nfunction loadProtobufResponseEncoder(): protobuf.Type {\n if (exportTraceServiceResponseType) return exportTraceServiceResponseType\n const root = loadProtoRoot()\n exportTraceServiceResponseType = root.lookupType(\n 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse',\n )\n return exportTraceServiceResponseType\n}\n\n// Empty-partial-success response, encoded once and cached. Per ADR-033 the\n// receiver always reports \"all accepted\" today — there's no per-span reject\n// path. Caching the bytes avoids re-running the protobuf encoder per request.\nlet cachedProtobufResponseBody: Buffer | null = null\n\nfunction encodeProtobufResponseBody(): Buffer {\n if (cachedProtobufResponseBody) return cachedProtobufResponseBody\n const Type = loadProtobufResponseEncoder()\n // `partial_success` left unset = empty submessage = \"everything accepted\".\n // verify() returns null on success; the empty payload is always valid.\n const msg = Type.create({})\n const encoded = Type.encode(msg).finish()\n cachedProtobufResponseBody = Buffer.from(encoded)\n return cachedProtobufResponseBody\n}\n\nasync function decodeProtobufBody(buf: Buffer): Promise<OtlpTracesRequest> {\n const Type = loadProtobufDecoder()\n // Decode keeps the proto field names verbatim (keepCase: true), matching the\n // GrpcExportRequest shape that reshapeGrpcRequest already understands.\n // Dynamic import sidesteps the circular module dep with otel-grpc.ts.\n const decoded = Type.decode(buf).toJSON() as Record<string, unknown>\n const { reshapeGrpcRequest } = await import('./otel-grpc.js')\n return reshapeGrpcRequest(decoded as never)\n}\n\nexport async function buildOtelReceiver(\n opts: BuildOtelReceiverOptions,\n): Promise<FastifyInstance & { flushPending: () => Promise<void> }> {\n const app = Fastify({\n logger: false,\n bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024,\n })\n\n // Non-blocking ingest (ADR-033). The receiver replies 200 OK as soon as the\n // body is parsed; mutation runs through this queue, drained on the next tick.\n // OTel SDK exporters retry on timeout, so blocking ingest produces observable\n // backpressure on the system being observed — ambient observation requires no\n // observable effect.\n const queue: ParsedSpan[] = []\n let draining = false\n let drainPromise: Promise<void> = Promise.resolve()\n\n const drain = async (): Promise<void> => {\n if (draining) return\n draining = true\n try {\n while (queue.length > 0) {\n const span = queue.shift()!\n try {\n await opts.onSpan(span)\n } catch (err) {\n console.warn(`[neat] otel handler error: ${(err as Error).message}`)\n }\n }\n } finally {\n draining = false\n }\n }\n\n const enqueue = (spans: ParsedSpan[]): void => {\n if (spans.length === 0) return\n for (const s of spans) queue.push(s)\n // Schedule on the next tick so the 200 response is on the wire before any\n // mutation runs. Each call gets its own promise so flushPending() can wait\n // on the latest drain cycle.\n drainPromise = drainPromise.then(() => drain())\n }\n\n // Buffer application/x-protobuf bodies as raw bytes; the route handler\n // decodes them via the bundled .proto tree (ADR-020).\n app.addContentTypeParser(\n 'application/x-protobuf',\n { parseAs: 'buffer', bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024 },\n (_req, body, done) => {\n done(null, body)\n },\n )\n\n app.get('/health', async () => ({ ok: true }))\n\n app.post('/v1/traces', async (req, reply) => {\n // Content-Type dispatch (ADR-033). Both JSON and protobuf decode to the\n // same OtlpTracesRequest shape, then feed parseOtlpRequest unchanged.\n // The response encoding mirrors the request encoding per the OTLP spec —\n // a JSON exporter receives JSON, a protobuf exporter receives protobuf.\n // Mismatched encodings cause client SDKs to log decode errors every batch.\n const ct = (req.headers['content-type'] ?? '').toString().split(';')[0]!.trim().toLowerCase()\n let body: OtlpTracesRequest\n let responseFlavor: 'json' | 'protobuf'\n if (ct === 'application/x-protobuf') {\n responseFlavor = 'protobuf'\n try {\n body = await decodeProtobufBody(req.body as Buffer)\n } catch (err) {\n return reply.code(400).send({\n error: `protobuf decode failed: ${(err as Error).message}`,\n })\n }\n } else if (!ct || ct === 'application/json') {\n responseFlavor = 'json'\n body = (req.body ?? {}) as OtlpTracesRequest\n } else {\n return reply.code(415).send({ error: `unsupported content-type: ${ct}` })\n }\n const spans = parseOtlpRequest(body)\n // Synchronous error-event write before reply (ADR-033 §Error events).\n // Graph mutation stays on the async queue, but the receiver awaits the\n // file write so a write failure surfaces as 500 → OTel SDK retries.\n if (opts.onErrorSpanSync) {\n try {\n for (const span of spans) {\n if (span.statusCode === 2) await opts.onErrorSpanSync(span)\n }\n } catch (err) {\n return reply.code(500).send({\n error: `error-event write failed: ${(err as Error).message}`,\n })\n }\n }\n enqueue(spans)\n // OTLP success response is `{ partialSuccess: {} }` for \"all accepted\".\n // Match the response Content-Type to the request: protobuf in → protobuf\n // out, JSON in → JSON out. Default Fastify JSON reply path stays as the\n // historical shape for JSON callers.\n if (responseFlavor === 'protobuf') {\n const buf = encodeProtobufResponseBody()\n return reply\n .code(200)\n .header('content-type', 'application/x-protobuf')\n .send(buf)\n }\n return reply\n .code(200)\n .header('content-type', 'application/json')\n .send({ partialSuccess: {} })\n })\n\n // Attach flushPending so tests can wait for the queue without exporting a\n // separate handle. The cast goes through `unknown` because Fastify's typing\n // is parameterised over the raw server type and the simple intersection\n // confuses TS's structural narrowing.\n const decorated = app as unknown as FastifyInstance & { flushPending: () => Promise<void> }\n decorated.flushPending = async () => {\n // Settle the current drain chain, then loop until the queue is fully empty\n // (a span enqueued mid-flush would otherwise be missed).\n while (queue.length > 0 || draining) {\n await drainPromise\n }\n }\n return decorated\n}\n\nexport function logSpanHandler(span: ParsedSpan): void {\n const parent = span.parentSpanId ? span.parentSpanId.slice(0, 8) : '<root>'\n const status = span.statusCode === 2 ? 'ERROR' : 'OK'\n const db = span.dbSystem ? ` db=${span.dbSystem}/${span.dbName ?? '?'}` : ''\n console.log(\n `otel: ${span.service} ${span.name} parent=${parent} status=${status}${db}`,\n )\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseOtlpRequest
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-75IBA4UL.js";
|
|
4
4
|
|
|
5
5
|
// src/otel-grpc.ts
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
@@ -122,4 +122,4 @@ export {
|
|
|
122
122
|
reshapeGrpcRequest,
|
|
123
123
|
startOtelGrpcReceiver
|
|
124
124
|
};
|
|
125
|
-
//# sourceMappingURL=chunk-
|
|
125
|
+
//# sourceMappingURL=chunk-CY67YKNO.js.map
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "./chunk-ZU2RQRCN.js";
|
|
19
19
|
import {
|
|
20
20
|
buildOtelReceiver
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-75IBA4UL.js";
|
|
22
22
|
|
|
23
23
|
// src/daemon.ts
|
|
24
24
|
import { promises as fs } from "fs";
|
|
@@ -33,13 +33,35 @@ function neatHomeFor(opts) {
|
|
|
33
33
|
function routeSpanToProject(serviceName, projects) {
|
|
34
34
|
if (!serviceName) return DEFAULT_PROJECT;
|
|
35
35
|
for (const entry of projects) {
|
|
36
|
-
if (entry.status
|
|
37
|
-
if (entry.languages.length === 0) {
|
|
38
|
-
}
|
|
36
|
+
if (entry.status === "paused") continue;
|
|
39
37
|
if (entry.name === serviceName) return entry.name;
|
|
40
38
|
}
|
|
39
|
+
const candidates = [];
|
|
40
|
+
for (const entry of projects) {
|
|
41
|
+
if (entry.status === "paused") continue;
|
|
42
|
+
if (isTokenPrefix(entry.name, serviceName)) candidates.push(entry);
|
|
43
|
+
}
|
|
44
|
+
if (candidates.length > 0) {
|
|
45
|
+
candidates.sort((a, b) => b.name.length - a.name.length);
|
|
46
|
+
return candidates[0].name;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of projects) {
|
|
49
|
+
if (entry.status === "paused") continue;
|
|
50
|
+
if (isTokenContained(entry.name, serviceName)) return entry.name;
|
|
51
|
+
}
|
|
41
52
|
return DEFAULT_PROJECT;
|
|
42
53
|
}
|
|
54
|
+
function isTokenPrefix(prefix, full) {
|
|
55
|
+
if (prefix.length >= full.length) return false;
|
|
56
|
+
if (!full.startsWith(prefix)) return false;
|
|
57
|
+
const sep = full.charAt(prefix.length);
|
|
58
|
+
return sep === "-" || sep === "_";
|
|
59
|
+
}
|
|
60
|
+
function isTokenContained(needle, haystack) {
|
|
61
|
+
if (!haystack.includes(needle)) return false;
|
|
62
|
+
const tokens = haystack.split(/[-_]/);
|
|
63
|
+
return tokens.includes(needle);
|
|
64
|
+
}
|
|
43
65
|
async function bootstrapProject(entry) {
|
|
44
66
|
const paths = pathsForProject(entry.name, path.join(entry.path, "neat-out"));
|
|
45
67
|
try {
|
|
@@ -119,6 +141,17 @@ async function startDaemon(opts = {}) {
|
|
|
119
141
|
`);
|
|
120
142
|
const slots = /* @__PURE__ */ new Map();
|
|
121
143
|
const registry = new Projects();
|
|
144
|
+
const DROP_WARN_INTERVAL_MS = 6e4;
|
|
145
|
+
const lastDropWarnAt = /* @__PURE__ */ new Map();
|
|
146
|
+
function warnDroppedSpan(project, reason) {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const prev = lastDropWarnAt.get(project) ?? 0;
|
|
149
|
+
if (now - prev < DROP_WARN_INTERVAL_MS) return;
|
|
150
|
+
lastDropWarnAt.set(project, now);
|
|
151
|
+
console.warn(
|
|
152
|
+
`[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
122
155
|
function upsertRegistryFromSlot(slot) {
|
|
123
156
|
if (slot.status !== "active") return;
|
|
124
157
|
registry.set(slot.entry.name, {
|
|
@@ -127,12 +160,38 @@ async function startDaemon(opts = {}) {
|
|
|
127
160
|
graph: slot.graph
|
|
128
161
|
});
|
|
129
162
|
}
|
|
163
|
+
async function tryRecoverSlot(entry) {
|
|
164
|
+
try {
|
|
165
|
+
const fresh = await bootstrapProject(entry);
|
|
166
|
+
slots.set(entry.name, fresh);
|
|
167
|
+
upsertRegistryFromSlot(fresh);
|
|
168
|
+
if (fresh.status === "active") {
|
|
169
|
+
await setStatus(entry.name, "active").catch(() => {
|
|
170
|
+
});
|
|
171
|
+
console.log(
|
|
172
|
+
`neatd: project "${entry.name}" recovered from broken \u2014 active`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return fresh;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn(
|
|
178
|
+
`neatd: project "${entry.name}" still broken after recovery attempt \u2014 ${err.message}`
|
|
179
|
+
);
|
|
180
|
+
return slots.get(entry.name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
130
183
|
async function loadAll() {
|
|
131
184
|
const projects = await listProjects();
|
|
132
185
|
const seen = /* @__PURE__ */ new Set();
|
|
133
186
|
for (const entry of projects) {
|
|
134
187
|
seen.add(entry.name);
|
|
135
|
-
|
|
188
|
+
const existing = slots.get(entry.name);
|
|
189
|
+
if (existing) {
|
|
190
|
+
if (existing.status === "broken") {
|
|
191
|
+
await tryRecoverSlot(entry);
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
136
195
|
try {
|
|
137
196
|
const slot = await bootstrapProject(entry);
|
|
138
197
|
slots.set(entry.name, slot);
|
|
@@ -189,13 +248,28 @@ async function startDaemon(opts = {}) {
|
|
|
189
248
|
`neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
|
|
190
249
|
);
|
|
191
250
|
}
|
|
251
|
+
async function resolveTargetSlot(serviceName) {
|
|
252
|
+
const liveEntries = await listProjects().catch(() => []);
|
|
253
|
+
const target = routeSpanToProject(serviceName, liveEntries);
|
|
254
|
+
let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
|
|
255
|
+
if (!slot) return null;
|
|
256
|
+
if (slot.status === "broken") {
|
|
257
|
+
const entry = liveEntries.find((e) => e.name === slot.entry.name);
|
|
258
|
+
if (entry) {
|
|
259
|
+
slot = await tryRecoverSlot(entry);
|
|
260
|
+
}
|
|
261
|
+
if (slot.status !== "active") {
|
|
262
|
+
warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return slot.status === "active" ? slot : null;
|
|
267
|
+
}
|
|
192
268
|
try {
|
|
193
269
|
otlpApp = await buildOtelReceiver({
|
|
194
270
|
onSpan: async (span) => {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
|
|
198
|
-
if (!slot || slot.status !== "active") return;
|
|
271
|
+
const slot = await resolveTargetSlot(span.service);
|
|
272
|
+
if (!slot) return;
|
|
199
273
|
await handleSpan(
|
|
200
274
|
{
|
|
201
275
|
graph: slot.graph,
|
|
@@ -208,10 +282,8 @@ async function startDaemon(opts = {}) {
|
|
|
208
282
|
);
|
|
209
283
|
},
|
|
210
284
|
onErrorSpanSync: async (span) => {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
|
|
214
|
-
if (!slot || slot.status !== "active") return;
|
|
285
|
+
const slot = await resolveTargetSlot(span.service);
|
|
286
|
+
if (!slot) return;
|
|
215
287
|
await makeErrorSpanWriter(slot.paths.errorsPath)(span);
|
|
216
288
|
}
|
|
217
289
|
});
|
|
@@ -278,4 +350,4 @@ export {
|
|
|
278
350
|
routeSpanToProject,
|
|
279
351
|
startDaemon
|
|
280
352
|
};
|
|
281
|
-
//# sourceMappingURL=chunk-
|
|
353
|
+
//# sourceMappingURL=chunk-EDHOCFOG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/daemon.ts"],"sourcesContent":["/**\n * Multi-project daemon (ADR-049).\n *\n * Single long-lived process watching every project in the machine-level registry.\n * Per-project graph isolation: each registered project owns its own\n * `MultiDirectedGraph` slot keyed by name (ADR-026), and a failure during\n * one project's bootstrap is logged + marked `broken` without taking down\n * the rest of the daemon.\n *\n * MVP scope (v0.2.5):\n * - Read registry; refuse to boot when it's missing.\n * - Write PID at `~/.neat/neatd.pid` for external supervisors.\n * - Per project: load any existing snapshot, run initial extraction,\n * start a per-project persist loop.\n * - SIGHUP triggers a reload — re-reads the registry, picks up new\n * projects, drops removed ones, leaves untouched ones in place.\n * - Provide `routeSpanToProject(serviceName, projects)` for OTel ingest\n * to dispatch by `service.name` across registered projects, falling\n * back to `default` for unknown services per ADR-033.\n *\n * Out of MVP scope (deferred):\n * - Live OTel listener wiring per project — daemon exposes the routing\n * primitive; the actual receiver attachment lands alongside v0.2.6.\n * - Policy reload on `policy.json` mtime — `startWatch` already does this\n * per-project; the daemon-level loop reuses that machinery in a follow-up.\n * - Auto-restart on crash. PID file is the supervisor handoff.\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport type { FastifyInstance } from 'fastify'\nimport { DEFAULT_PROJECT, getGraph, resetGraph, type NeatGraph } from './graph.js'\nimport { extractFromDirectory } from './extract.js'\nimport { loadGraphFromDisk, startPersistLoop } from './persist.js'\nimport { Projects, pathsForProject, type ProjectPaths } from './projects.js'\nimport { buildApi } from './api.js'\nimport { buildOtelReceiver } from './otel.js'\nimport { handleSpan, makeErrorSpanWriter } from './ingest.js'\nimport {\n listProjects,\n registryPath,\n setStatus,\n touchLastSeen,\n writeAtomically,\n} from './registry.js'\nimport type { RegistryEntry } from '@neat.is/types'\n\nexport interface DaemonOptions {\n // Defaults to `~/.neat/`. Honors NEAT_HOME the same way registry.ts does.\n // Tests override via NEAT_HOME and don't pass this directly.\n neatHome?: string\n // ADR-063 — bind targets. Defaults to PORT (8080) / OTEL_PORT (4318) env\n // vars, matching server.ts. Tests pass 0 to get ephemeral ports.\n restPort?: number\n otlpPort?: number\n // ADR-063 — bind host. Defaults to HOST env (0.0.0.0).\n host?: string\n // ADR-063 — opt out of binding entirely (e.g. integration tests that\n // exercise daemon slots without needing the listeners). Production\n // `neatd start` never sets this.\n bindListeners?: boolean\n}\n\nexport interface ProjectSlot {\n entry: RegistryEntry\n graph: NeatGraph\n outPath: string\n paths: ProjectPaths\n stopPersist: () => void\n status: 'active' | 'broken'\n errorReason?: string\n}\n\nexport interface DaemonHandle {\n // The slots currently being managed, keyed by project name. Tests inspect\n // this to assert isolation properties.\n slots: Map<string, ProjectSlot>\n // Re-read the registry. New entries get bootstrapped, removed ones get\n // their persist loops stopped, existing ones stay running.\n reload: () => Promise<void>\n // Graceful shutdown — stop every project's persist loop and remove the\n // PID file.\n stop: () => Promise<void>\n // Path to the PID file the daemon owns. Useful for test assertions.\n pidPath: string\n // ADR-063 — addresses where consumers reach the daemon. Empty string when\n // bindListeners is false. REST is the Fastify app's listening address;\n // OTLP is the receiver's.\n restAddress: string\n otlpAddress: string\n}\n\nfunction neatHomeFor(opts: DaemonOptions): string {\n if (opts.neatHome && opts.neatHome.length > 0) return path.resolve(opts.neatHome)\n const env = process.env.NEAT_HOME\n if (env && env.length > 0) return path.resolve(env)\n const home = process.env.HOME ?? process.env.USERPROFILE ?? ''\n return path.join(home, '.neat')\n}\n\n/**\n * Resolve which project's graph an OTel span belongs to. Looks up the\n * `service.name` against the registry and returns the matching project's\n * name, or `DEFAULT_PROJECT` for unknown services so the FrontierNode\n * auto-creation flow keeps working per ADR-033.\n *\n * Pure function. Daemon callers pass a snapshot of the registry to avoid\n * per-span fs reads.\n *\n * Matching order (ADR-072 — real-world `service.name` rarely equals project\n * name; monorepos publish per-package names like `brief-api` under a\n * project named `brief`):\n *\n * 1. Exact: `entry.name === serviceName`.\n * 2. Hyphen/underscore-separated prefix: `entry.name` is a leading token\n * of `serviceName` (`brief` matches `brief-api`, `brief_worker`).\n * Longest-match wins so `brief-api` beats `brief` when both are\n * registered.\n * 3. Containment as a separator-delimited token (`api` inside\n * `brief-api-staging`).\n *\n * Routing eligibility (ADR-071):\n * - `active` matches at every pass (the steady-state path).\n * - `broken` also matches — the daemon needs the span to reach the broken\n * slot so the ingest-time auto-recover path can attempt a bootstrap and\n * lift the project back to `active`. The router only chooses the target;\n * whether the span actually lands is the ingest handler's decision.\n * - `paused` is intentionally not routed; the operator paused it on\n * purpose, so the span falls through to the default-project flow.\n *\n * Falls back to `DEFAULT_PROJECT` when nothing matches.\n */\nexport function routeSpanToProject(\n serviceName: string | undefined,\n projects: ReadonlyArray<RegistryEntry>,\n): string {\n if (!serviceName) return DEFAULT_PROJECT\n // Pass 1 — exact match.\n for (const entry of projects) {\n if (entry.status === 'paused') continue\n if (entry.name === serviceName) return entry.name\n }\n // Pass 2 — hyphen/underscore-separated prefix. Longest project name wins\n // so a registered `brief-api` outranks a registered `brief` when the\n // span's service.name is `brief-api-staging`.\n const candidates: RegistryEntry[] = []\n for (const entry of projects) {\n if (entry.status === 'paused') continue\n if (isTokenPrefix(entry.name, serviceName)) candidates.push(entry)\n }\n if (candidates.length > 0) {\n candidates.sort((a, b) => b.name.length - a.name.length)\n return candidates[0]!.name\n }\n // Pass 3 — containment as a separator-delimited token. Last-resort match\n // for `api` inside `brief-api-staging` when only `api` is registered.\n for (const entry of projects) {\n if (entry.status === 'paused') continue\n if (isTokenContained(entry.name, serviceName)) return entry.name\n }\n return DEFAULT_PROJECT\n}\n\n// True when `prefix` matches the first hyphen/underscore-separated token(s)\n// of `full`. `brief` matches `brief-api`, `brief_worker`, but not `briefcase`.\nfunction isTokenPrefix(prefix: string, full: string): boolean {\n if (prefix.length >= full.length) return false\n if (!full.startsWith(prefix)) return false\n const sep = full.charAt(prefix.length)\n return sep === '-' || sep === '_'\n}\n\n// True when `needle` appears in `haystack` bordered by separators on both\n// sides (so it's a complete token, not a substring of a longer word).\nfunction isTokenContained(needle: string, haystack: string): boolean {\n if (!haystack.includes(needle)) return false\n const tokens = haystack.split(/[-_]/)\n return tokens.includes(needle)\n}\n\nasync function bootstrapProject(entry: RegistryEntry): Promise<ProjectSlot> {\n const paths = pathsForProject(entry.name, path.join(entry.path, 'neat-out'))\n\n // Path missing on disk → mark broken and surface the reason. Daemon\n // continues with the rest of the registry.\n try {\n const stat = await fs.stat(entry.path)\n if (!stat.isDirectory()) {\n throw new Error(`registered path ${entry.path} is not a directory`)\n }\n } catch (err) {\n await setStatus(entry.name, 'broken').catch(() => {})\n return {\n entry,\n // Empty graph is fine — `slots` keeps the entry visible in `status`\n // output; nothing routes to it because it's not 'active'.\n graph: getGraph(`__broken__:${entry.name}`),\n outPath: '',\n paths,\n stopPersist: () => {},\n status: 'broken',\n errorReason: (err as Error).message,\n }\n }\n\n // Use the project name as the in-memory graph key. Any prior contents\n // are wiped because the daemon owns the slot for the lifetime of this\n // bootstrap (ADR-030 — mutation authority).\n resetGraph(entry.name)\n const graph = getGraph(entry.name)\n const outPath = paths.snapshotPath\n\n await loadGraphFromDisk(graph, outPath)\n await extractFromDirectory(graph, entry.path)\n const stopPersist = startPersistLoop(graph, outPath)\n await touchLastSeen(entry.name).catch(() => {})\n\n return {\n entry,\n graph,\n outPath,\n paths,\n stopPersist,\n status: 'active',\n }\n}\n\nfunction resolveRestPort(opts: DaemonOptions): number {\n if (typeof opts.restPort === 'number') return opts.restPort\n const env = process.env.PORT\n if (env && env.length > 0) {\n const n = Number.parseInt(env, 10)\n if (Number.isFinite(n)) return n\n }\n return 8080\n}\n\nfunction resolveOtlpPort(opts: DaemonOptions): number {\n if (typeof opts.otlpPort === 'number') return opts.otlpPort\n const env = process.env.OTEL_PORT\n if (env && env.length > 0) {\n const n = Number.parseInt(env, 10)\n if (Number.isFinite(n)) return n\n }\n return 4318\n}\n\nfunction resolveHost(opts: DaemonOptions): string {\n if (opts.host && opts.host.length > 0) return opts.host\n const env = process.env.HOST\n if (env && env.length > 0) return env\n return '0.0.0.0'\n}\n\nexport async function startDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {\n const home = neatHomeFor(opts)\n const regPath = registryPath()\n // Graceful degradation per ADR-049 #6: missing registry refuses to boot\n // with a clear error rather than silently coming up empty.\n try {\n await fs.access(regPath)\n } catch {\n throw new Error(\n `neatd: registry not found at ${regPath}. Run \\`neat init <path>\\` to register a project before starting the daemon.`,\n )\n }\n\n const pidPath = path.join(home, 'neatd.pid')\n await writeAtomically(pidPath, `${process.pid}\\n`)\n\n const slots = new Map<string, ProjectSlot>()\n // Projects registry mirrors slots for the REST listener (ADR-063). buildApi\n // reads from this; we keep it in sync as slots come and go.\n const registry = new Projects()\n\n // Rate-limit the dropped-span warning to one log line per project per\n // 60 seconds. OTel exporters retry on a tight cadence; without this we\n // flood the console with the same line per batch when a broken project\n // is sitting in the registry.\n const DROP_WARN_INTERVAL_MS = 60_000\n const lastDropWarnAt = new Map<string, number>()\n function warnDroppedSpan(project: string, reason: string): void {\n const now = Date.now()\n const prev = lastDropWarnAt.get(project) ?? 0\n if (now - prev < DROP_WARN_INTERVAL_MS) return\n lastDropWarnAt.set(project, now)\n console.warn(\n `[neatd] dropping span for project \"${project}\" — project status: broken (${reason}). Run \\`neatd reload\\` to retry bootstrap.`,\n )\n }\n\n function upsertRegistryFromSlot(slot: ProjectSlot): void {\n if (slot.status !== 'active') return\n registry.set(slot.entry.name, {\n scanPath: slot.entry.path,\n paths: slot.paths,\n graph: slot.graph,\n })\n }\n\n // Attempt to bring a broken slot back online. Used both on SIGHUP reload\n // and inline on ingest when a span arrives for a broken project. Returns\n // the new slot status so callers can decide whether to deliver the span.\n async function tryRecoverSlot(entry: RegistryEntry): Promise<ProjectSlot> {\n try {\n const fresh = await bootstrapProject(entry)\n slots.set(entry.name, fresh)\n upsertRegistryFromSlot(fresh)\n if (fresh.status === 'active') {\n await setStatus(entry.name, 'active').catch(() => {})\n console.log(\n `neatd: project \"${entry.name}\" recovered from broken — active`,\n )\n }\n return fresh\n } catch (err) {\n console.warn(\n `neatd: project \"${entry.name}\" still broken after recovery attempt — ${(err as Error).message}`,\n )\n // Leave the existing broken slot in place; nothing changed.\n return slots.get(entry.name)!\n }\n }\n\n async function loadAll(): Promise<void> {\n const projects = await listProjects()\n const seen = new Set<string>()\n for (const entry of projects) {\n seen.add(entry.name)\n const existing = slots.get(entry.name)\n if (existing) {\n // SIGHUP shortcut: re-bootstrap any slot currently in `broken`. The\n // operator's mental model for `neatd reload` is \"look at the world\n // again,\" so a broken slot that became reachable between boots gets\n // a second chance.\n if (existing.status === 'broken') {\n await tryRecoverSlot(entry)\n }\n continue\n }\n try {\n const slot = await bootstrapProject(entry)\n slots.set(entry.name, slot)\n upsertRegistryFromSlot(slot)\n if (slot.status === 'broken') {\n console.warn(`neatd: project \"${entry.name}\" broken — ${slot.errorReason}`)\n } else {\n console.log(`neatd: project \"${entry.name}\" active (${entry.path})`)\n }\n } catch (err) {\n console.warn(\n `neatd: project \"${entry.name}\" failed to bootstrap — ${(err as Error).message}`,\n )\n await setStatus(entry.name, 'broken').catch(() => {})\n }\n }\n // Drop entries the registry no longer carries.\n for (const [name, slot] of [...slots.entries()]) {\n if (seen.has(name)) continue\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n slots.delete(name)\n console.log(`neatd: project \"${name}\" removed from registry — stopped`)\n }\n }\n\n await loadAll()\n\n // ADR-063 — bind the REST host and the OTLP HTTP receiver. One listener\n // each, multi-tenant by project name in the URL (REST) and by service.name\n // dispatch (OTLP). Failure on either listen aborts startDaemon with a\n // surfacing error rather than letting the supervisor sit half-up.\n const bind = opts.bindListeners !== false\n let restApp: FastifyInstance | null = null\n let otlpApp:\n | (FastifyInstance & { flushPending: () => Promise<void> })\n | null = null\n let restAddress = ''\n let otlpAddress = ''\n\n if (bind) {\n const host = resolveHost(opts)\n const restPort = resolveRestPort(opts)\n const otlpPort = resolveOtlpPort(opts)\n\n try {\n restApp = await buildApi({ projects: registry })\n restAddress = await restApp.listen({ port: restPort, host })\n console.log(`neatd: REST listening on ${restAddress}`)\n } catch (err) {\n // Roll back anything we started so far before surfacing the error.\n for (const slot of slots.values()) {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n }\n if (restApp) await restApp.close().catch(() => {})\n await fs.unlink(pidPath).catch(() => {})\n throw new Error(\n `neatd: failed to bind REST on port ${restPort} — ${(err as Error).message}`,\n )\n }\n\n // Resolve a span's target slot — running the broken-state recovery\n // when the routed slot is currently broken. Returns null when the span\n // can't be delivered after the recovery attempt; the caller drops with\n // a rate-limited warning.\n async function resolveTargetSlot(\n serviceName: string | undefined,\n ): Promise<ProjectSlot | null> {\n const liveEntries = await listProjects().catch(() => [])\n const target = routeSpanToProject(serviceName, liveEntries)\n let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT)\n if (!slot) return null\n if (slot.status === 'broken') {\n const entry = liveEntries.find((e) => e.name === slot!.entry.name)\n if (entry) {\n slot = await tryRecoverSlot(entry)\n }\n if (slot.status !== 'active') {\n warnDroppedSpan(slot.entry.name, slot.errorReason ?? 'unknown')\n return null\n }\n }\n return slot.status === 'active' ? slot : null\n }\n\n try {\n otlpApp = await buildOtelReceiver({\n onSpan: async (span) => {\n // ADR-049 OTel routing — dispatch by service.name. Broken slots\n // get a single inline recovery attempt before the span is dropped\n // with a rate-limited log line. Unknown services route to\n // DEFAULT_PROJECT so the FrontierNode auto-creation flow keeps\n // working (ADR-033).\n const slot = await resolveTargetSlot(span.service)\n if (!slot) return\n await handleSpan(\n {\n graph: slot.graph,\n errorsPath: slot.paths.errorsPath,\n project: slot.entry.name,\n // Receiver already wrote the error event synchronously below.\n writeErrorEventInline: false,\n },\n span,\n )\n },\n onErrorSpanSync: async (span) => {\n const slot = await resolveTargetSlot(span.service)\n if (!slot) return\n await makeErrorSpanWriter(slot.paths.errorsPath)(span)\n },\n })\n otlpAddress = await otlpApp.listen({ port: otlpPort, host })\n console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`)\n } catch (err) {\n for (const slot of slots.values()) {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n }\n if (restApp) await restApp.close().catch(() => {})\n if (otlpApp) await otlpApp.close().catch(() => {})\n await fs.unlink(pidPath).catch(() => {})\n throw new Error(\n `neatd: failed to bind OTLP on port ${otlpPort} — ${(err as Error).message}`,\n )\n }\n }\n\n let reloading: Promise<void> | null = null\n const reload = async (): Promise<void> => {\n if (reloading) return reloading\n reloading = (async () => {\n try {\n await loadAll()\n } finally {\n reloading = null\n }\n })()\n return reloading\n }\n\n // SIGHUP — external \"reload your config\" signal. ADR-049 #2.\n const sighupHandler = (): void => {\n void reload().catch((err) => {\n console.warn(`neatd: SIGHUP reload failed — ${(err as Error).message}`)\n })\n }\n process.on('SIGHUP', sighupHandler)\n\n let stopped = false\n const stop = async (): Promise<void> => {\n if (stopped) return\n stopped = true\n process.off('SIGHUP', sighupHandler)\n if (otlpApp) await otlpApp.close().catch(() => {})\n if (restApp) await restApp.close().catch(() => {})\n for (const slot of slots.values()) {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n }\n await fs.unlink(pidPath).catch(() => {})\n }\n\n return { slots, reload, stop, pidPath, restAddress, otlpAddress }\n}\n"],"mappings":"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","names":[]}
|
package/dist/cli.cjs
CHANGED
|
@@ -258,8 +258,7 @@ function parseOtlpRequest(body) {
|
|
|
258
258
|
}
|
|
259
259
|
return out;
|
|
260
260
|
}
|
|
261
|
-
function
|
|
262
|
-
if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
|
|
261
|
+
function loadProtoRoot() {
|
|
263
262
|
const here = import_node_path35.default.dirname((0, import_node_url2.fileURLToPath)(importMetaUrl));
|
|
264
263
|
const protoRoot = import_node_path35.default.resolve(here, "..", "proto");
|
|
265
264
|
const root = new import_protobufjs.default.Root();
|
|
@@ -268,11 +267,32 @@ function loadProtobufDecoder() {
|
|
|
268
267
|
"opentelemetry/proto/collector/trace/v1/trace_service.proto",
|
|
269
268
|
{ keepCase: true }
|
|
270
269
|
);
|
|
270
|
+
return root;
|
|
271
|
+
}
|
|
272
|
+
function loadProtobufDecoder() {
|
|
273
|
+
if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
|
|
274
|
+
const root = loadProtoRoot();
|
|
271
275
|
exportTraceServiceRequestType = root.lookupType(
|
|
272
276
|
"opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"
|
|
273
277
|
);
|
|
274
278
|
return exportTraceServiceRequestType;
|
|
275
279
|
}
|
|
280
|
+
function loadProtobufResponseEncoder() {
|
|
281
|
+
if (exportTraceServiceResponseType) return exportTraceServiceResponseType;
|
|
282
|
+
const root = loadProtoRoot();
|
|
283
|
+
exportTraceServiceResponseType = root.lookupType(
|
|
284
|
+
"opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse"
|
|
285
|
+
);
|
|
286
|
+
return exportTraceServiceResponseType;
|
|
287
|
+
}
|
|
288
|
+
function encodeProtobufResponseBody() {
|
|
289
|
+
if (cachedProtobufResponseBody) return cachedProtobufResponseBody;
|
|
290
|
+
const Type = loadProtobufResponseEncoder();
|
|
291
|
+
const msg = Type.create({});
|
|
292
|
+
const encoded = Type.encode(msg).finish();
|
|
293
|
+
cachedProtobufResponseBody = Buffer.from(encoded);
|
|
294
|
+
return cachedProtobufResponseBody;
|
|
295
|
+
}
|
|
276
296
|
async function decodeProtobufBody(buf) {
|
|
277
297
|
const Type = loadProtobufDecoder();
|
|
278
298
|
const decoded = Type.decode(buf).toJSON();
|
|
@@ -319,7 +339,9 @@ async function buildOtelReceiver(opts) {
|
|
|
319
339
|
app.post("/v1/traces", async (req, reply) => {
|
|
320
340
|
const ct = (req.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
|
|
321
341
|
let body;
|
|
342
|
+
let responseFlavor;
|
|
322
343
|
if (ct === "application/x-protobuf") {
|
|
344
|
+
responseFlavor = "protobuf";
|
|
323
345
|
try {
|
|
324
346
|
body = await decodeProtobufBody(req.body);
|
|
325
347
|
} catch (err) {
|
|
@@ -328,6 +350,7 @@ async function buildOtelReceiver(opts) {
|
|
|
328
350
|
});
|
|
329
351
|
}
|
|
330
352
|
} else if (!ct || ct === "application/json") {
|
|
353
|
+
responseFlavor = "json";
|
|
331
354
|
body = req.body ?? {};
|
|
332
355
|
} else {
|
|
333
356
|
return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
|
|
@@ -345,7 +368,11 @@ async function buildOtelReceiver(opts) {
|
|
|
345
368
|
}
|
|
346
369
|
}
|
|
347
370
|
enqueue(spans);
|
|
348
|
-
|
|
371
|
+
if (responseFlavor === "protobuf") {
|
|
372
|
+
const buf = encodeProtobufResponseBody();
|
|
373
|
+
return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
|
|
374
|
+
}
|
|
375
|
+
return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
|
|
349
376
|
});
|
|
350
377
|
const decorated = app;
|
|
351
378
|
decorated.flushPending = async () => {
|
|
@@ -355,7 +382,7 @@ async function buildOtelReceiver(opts) {
|
|
|
355
382
|
};
|
|
356
383
|
return decorated;
|
|
357
384
|
}
|
|
358
|
-
var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType;
|
|
385
|
+
var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
|
|
359
386
|
var init_otel = __esm({
|
|
360
387
|
"src/otel.ts"() {
|
|
361
388
|
"use strict";
|
|
@@ -365,6 +392,8 @@ var init_otel = __esm({
|
|
|
365
392
|
import_fastify2 = __toESM(require("fastify"), 1);
|
|
366
393
|
import_protobufjs = __toESM(require("protobufjs"), 1);
|
|
367
394
|
exportTraceServiceRequestType = null;
|
|
395
|
+
exportTraceServiceResponseType = null;
|
|
396
|
+
cachedProtobufResponseBody = null;
|
|
368
397
|
}
|
|
369
398
|
});
|
|
370
399
|
|
|
@@ -5998,7 +6027,48 @@ async function detect(serviceDir) {
|
|
|
5998
6027
|
const pkg = await readPackageJson(serviceDir);
|
|
5999
6028
|
return pkg !== null && typeof pkg.name === "string";
|
|
6000
6029
|
}
|
|
6001
|
-
var
|
|
6030
|
+
var INDEX_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs"];
|
|
6031
|
+
var INDEX_CANDIDATES = INDEX_EXTENSIONS.map((ext) => `index${ext}`);
|
|
6032
|
+
var SRC_INDEX_CANDIDATES = INDEX_EXTENSIONS.map((ext) => `src/index${ext}`);
|
|
6033
|
+
var SRC_NAMED_CANDIDATES = ["server", "main", "app"].flatMap(
|
|
6034
|
+
(name) => INDEX_EXTENSIONS.map((ext) => `src/${name}${ext}`)
|
|
6035
|
+
);
|
|
6036
|
+
var SCRIPT_LAUNCHERS = /* @__PURE__ */ new Set([
|
|
6037
|
+
"node",
|
|
6038
|
+
"ts-node",
|
|
6039
|
+
"tsx",
|
|
6040
|
+
"ts-node-dev",
|
|
6041
|
+
"nodemon",
|
|
6042
|
+
"npx",
|
|
6043
|
+
"pnpm",
|
|
6044
|
+
"yarn",
|
|
6045
|
+
"npm",
|
|
6046
|
+
"cross-env",
|
|
6047
|
+
"dotenv",
|
|
6048
|
+
"--"
|
|
6049
|
+
]);
|
|
6050
|
+
function looksLikeEntryPath(token) {
|
|
6051
|
+
if (token.length === 0) return false;
|
|
6052
|
+
if (token.startsWith("-")) return false;
|
|
6053
|
+
if (token.includes("=")) return false;
|
|
6054
|
+
if (token.includes("/")) return true;
|
|
6055
|
+
return /\.(?:m?[jt]sx?|c[jt]s)$/.test(token);
|
|
6056
|
+
}
|
|
6057
|
+
function scriptHasShellChain(script) {
|
|
6058
|
+
return /(?:&&|\|\||;|\|(?!\|))/.test(script);
|
|
6059
|
+
}
|
|
6060
|
+
function entryFromScript(script) {
|
|
6061
|
+
if (!script) return void 0;
|
|
6062
|
+
if (scriptHasShellChain(script)) return void 0;
|
|
6063
|
+
const tokens = script.split(/\s+/).filter((t) => t.length > 0);
|
|
6064
|
+
for (const token of tokens) {
|
|
6065
|
+
const lower = token.toLowerCase();
|
|
6066
|
+
if (SCRIPT_LAUNCHERS.has(lower)) continue;
|
|
6067
|
+
const cleaned = token.startsWith("./") ? token.slice(2) : token;
|
|
6068
|
+
if (looksLikeEntryPath(cleaned)) return cleaned;
|
|
6069
|
+
}
|
|
6070
|
+
return void 0;
|
|
6071
|
+
}
|
|
6002
6072
|
async function resolveEntry(serviceDir, pkg) {
|
|
6003
6073
|
if (typeof pkg.main === "string" && pkg.main.length > 0) {
|
|
6004
6074
|
const candidate = import_node_path38.default.resolve(serviceDir, pkg.main);
|
|
@@ -6019,6 +6089,24 @@ async function resolveEntry(serviceDir, pkg) {
|
|
|
6019
6089
|
if (await exists2(candidate)) return candidate;
|
|
6020
6090
|
}
|
|
6021
6091
|
}
|
|
6092
|
+
const startEntry = entryFromScript(pkg.scripts?.start);
|
|
6093
|
+
if (startEntry) {
|
|
6094
|
+
const candidate = import_node_path38.default.resolve(serviceDir, startEntry);
|
|
6095
|
+
if (await exists2(candidate)) return candidate;
|
|
6096
|
+
}
|
|
6097
|
+
const devEntry = entryFromScript(pkg.scripts?.dev);
|
|
6098
|
+
if (devEntry) {
|
|
6099
|
+
const candidate = import_node_path38.default.resolve(serviceDir, devEntry);
|
|
6100
|
+
if (await exists2(candidate)) return candidate;
|
|
6101
|
+
}
|
|
6102
|
+
for (const rel of SRC_INDEX_CANDIDATES) {
|
|
6103
|
+
const candidate = import_node_path38.default.join(serviceDir, rel);
|
|
6104
|
+
if (await exists2(candidate)) return candidate;
|
|
6105
|
+
}
|
|
6106
|
+
for (const rel of SRC_NAMED_CANDIDATES) {
|
|
6107
|
+
const candidate = import_node_path38.default.join(serviceDir, rel);
|
|
6108
|
+
if (await exists2(candidate)) return candidate;
|
|
6109
|
+
}
|
|
6022
6110
|
for (const name of INDEX_CANDIDATES) {
|
|
6023
6111
|
const candidate = import_node_path38.default.join(serviceDir, name);
|
|
6024
6112
|
if (await exists2(candidate)) return candidate;
|