@neat.is/core 0.4.2 → 0.4.4
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-NTQHMXWE.js → chunk-G3YGPWJL.js} +2 -2
- package/dist/{chunk-CB2UK4EH.js → chunk-J4YBTD24.js} +44 -3
- package/dist/{chunk-CB2UK4EH.js.map → chunk-J4YBTD24.js.map} +1 -1
- package/dist/{chunk-D5PIJFBE.js → chunk-YJOA7BBF.js} +2 -2
- package/dist/{chunk-KYRIQIPG.js → chunk-ZVNP3ZDH.js} +97 -25
- package/dist/chunk-ZVNP3ZDH.js.map +1 -0
- package/dist/cli.cjs +307 -125
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +215 -105
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +136 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -4
- package/dist/neatd.cjs +136 -23
- package/dist/neatd.cjs.map +1 -1
- package/dist/neatd.js +3 -3
- package/dist/{otel-grpc-QTX2YQJZ.js → otel-grpc-FIERFRGD.js} +3 -3
- package/dist/server.cjs +95 -23
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +3 -3
- package/package.json +2 -2
- package/dist/chunk-KYRIQIPG.js.map +0 -1
- /package/dist/{chunk-NTQHMXWE.js.map → chunk-G3YGPWJL.js.map} +0 -0
- /package/dist/{chunk-D5PIJFBE.js.map → chunk-YJOA7BBF.js.map} +0 -0
- /package/dist/{otel-grpc-QTX2YQJZ.js.map → otel-grpc-FIERFRGD.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
mountBearerAuth
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ZVNP3ZDH.js";
|
|
4
4
|
|
|
5
5
|
// src/graph.ts
|
|
6
6
|
import GraphDefault from "graphology";
|
|
@@ -5126,4 +5126,4 @@ export {
|
|
|
5126
5126
|
removeProject,
|
|
5127
5127
|
buildApi
|
|
5128
5128
|
};
|
|
5129
|
-
//# sourceMappingURL=chunk-
|
|
5129
|
+
//# sourceMappingURL=chunk-G3YGPWJL.js.map
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
startPersistLoop,
|
|
16
16
|
touchLastSeen,
|
|
17
17
|
writeAtomically
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-G3YGPWJL.js";
|
|
19
19
|
import {
|
|
20
20
|
assertBindAuthority,
|
|
21
21
|
buildOtelReceiver,
|
|
22
22
|
readAuthEnv
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-ZVNP3ZDH.js";
|
|
24
24
|
|
|
25
25
|
// src/daemon.ts
|
|
26
26
|
import { promises as fs2 } from "fs";
|
|
@@ -345,6 +345,25 @@ async function startDaemon(opts = {}) {
|
|
|
345
345
|
}
|
|
346
346
|
return slot.status === "active" ? slot : null;
|
|
347
347
|
}
|
|
348
|
+
async function resolveSlotByName(project, serviceName, traceId) {
|
|
349
|
+
const liveEntries = await listProjects().catch(() => []);
|
|
350
|
+
let slot = slots.get(project);
|
|
351
|
+
if (!slot) {
|
|
352
|
+
await recordUnroutedSpan(serviceName, traceId);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
if (slot.status === "broken") {
|
|
356
|
+
const entry = liveEntries.find((e) => e.name === slot.entry.name);
|
|
357
|
+
if (entry) {
|
|
358
|
+
slot = await tryRecoverSlot(entry);
|
|
359
|
+
}
|
|
360
|
+
if (slot.status !== "active") {
|
|
361
|
+
warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return slot.status === "active" ? slot : null;
|
|
366
|
+
}
|
|
348
367
|
try {
|
|
349
368
|
otlpApp = await buildOtelReceiver({
|
|
350
369
|
authToken: auth.otelToken,
|
|
@@ -367,6 +386,28 @@ async function startDaemon(opts = {}) {
|
|
|
367
386
|
const slot = await resolveTargetSlot(span.service, span.traceId);
|
|
368
387
|
if (!slot) return;
|
|
369
388
|
await makeErrorSpanWriter(slot.paths.errorsPath)(span);
|
|
389
|
+
},
|
|
390
|
+
// Project-scoped route (issue #367) — the URL already named the
|
|
391
|
+
// project. Resolution is a direct slot lookup; service.name resolves
|
|
392
|
+
// the ServiceNode inside the slot's graph instead of which project
|
|
393
|
+
// owns the span.
|
|
394
|
+
onProjectSpan: async (project, span) => {
|
|
395
|
+
const slot = await resolveSlotByName(project, span.service, span.traceId);
|
|
396
|
+
if (!slot) return;
|
|
397
|
+
await handleSpan(
|
|
398
|
+
{
|
|
399
|
+
graph: slot.graph,
|
|
400
|
+
errorsPath: slot.paths.errorsPath,
|
|
401
|
+
project: slot.entry.name,
|
|
402
|
+
writeErrorEventInline: false
|
|
403
|
+
},
|
|
404
|
+
span
|
|
405
|
+
);
|
|
406
|
+
},
|
|
407
|
+
onProjectErrorSpanSync: async (project, span) => {
|
|
408
|
+
const slot = await resolveSlotByName(project, span.service, span.traceId);
|
|
409
|
+
if (!slot) return;
|
|
410
|
+
await makeErrorSpanWriter(slot.paths.errorsPath)(span);
|
|
370
411
|
}
|
|
371
412
|
});
|
|
372
413
|
otlpAddress = await otlpApp.listen({ port: otlpPort, host });
|
|
@@ -458,4 +499,4 @@ export {
|
|
|
458
499
|
routeSpanToProject,
|
|
459
500
|
startDaemon
|
|
460
501
|
};
|
|
461
|
-
//# sourceMappingURL=chunk-
|
|
502
|
+
//# sourceMappingURL=chunk-J4YBTD24.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/daemon.ts","../src/unrouted.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 { assertBindAuthority, readAuthEnv } from './auth.js'\nimport {\n appendUnroutedSpan,\n buildUnroutedSpanRecord,\n unroutedErrorsPath,\n} from './unrouted.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\n// Issue #340 — per-project bootstrap state surface. The REST listener\n// flips to live the moment `app.listen()` returns; per-project routes\n// branch on this rather than waiting for every registered project's\n// extractFromDirectory pass to finish.\nexport type BootstrapPhase = 'bootstrapping' | 'active' | 'broken'\n\nexport interface BootstrapTracker {\n status: (name: string) => BootstrapPhase | undefined\n list: () => Array<{ name: string; status: BootstrapPhase; elapsedMs: number }>\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 // Issue #340 — per-project bootstrap status, surfaced for orchestrator\n // poll loops and tests.\n bootstrap: BootstrapTracker\n // Resolves when the daemon's initial bootstrap pass has settled. Tests\n // that probe project-scoped routes immediately after startDaemon await\n // this; production callers use /health.\n initialBootstrap: Promise<void>\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, authTokenSet: boolean): 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 // Issue #341 — loopback-only default when the operator hasn't set a token.\n // Public-bind on a clean install demanded one before binding could\n // succeed, so the npx-`neat .` first-touch path used to refuse to come up;\n // pinning to 127.0.0.1 lets that path bind cleanly. Anyone wanting a\n // public bind sets `NEAT_AUTH_TOKEN` (and `HOST=0.0.0.0` if they want it\n // spelled out). `assertBindAuthority` stays exactly as it is — the\n // contract is right; the default was wrong.\n if (!authTokenSet) return '127.0.0.1'\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 // Issue #340 — per-project bootstrap status. Populated from the registry\n // before the listener binds so the REST handlers can return 503 instead of\n // 404 for projects still extracting.\n const bootstrapStatus = new Map<string, BootstrapPhase>()\n const bootstrapStartedAt = new Map<string, number>()\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 // v0.4.1 / refs #339 — when a span's `service.name` doesn't match any\n // registered project AND no `default` project is registered, the span has\n // nowhere to land. We still return 200 on the receiver (OTel spec) but the\n // event lands in <NEAT_HOME>/errors.ndjson so the next operator can see\n // what happened instead of the daemon's stderr being the only signal.\n // Same rate limit as the broken-project warning, keyed by service.name.\n const unroutedPath = unroutedErrorsPath(home)\n const lastUnroutedWarnAt = new Map<string, number>()\n async function recordUnroutedSpan(\n serviceName: string | undefined,\n traceId: string | undefined,\n ): Promise<void> {\n const key = serviceName ?? '<missing>'\n const now = Date.now()\n try {\n await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)))\n } catch {\n // best-effort — failing to log shouldn't cascade into receiver failure.\n }\n const prev = lastUnroutedWarnAt.get(key) ?? 0\n if (now - prev < DROP_WARN_INTERVAL_MS) return\n lastUnroutedWarnAt.set(key, now)\n console.warn(\n `[neatd] dropping span — service.name \"${key}\" matches no registered project and no \\`default\\` project exists. See ${unroutedPath}.`,\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 bootstrapOne(entry: RegistryEntry): Promise<void> {\n bootstrapStatus.set(entry.name, 'bootstrapping')\n bootstrapStartedAt.set(entry.name, Date.now())\n try {\n const slot = await bootstrapProject(entry)\n slots.set(entry.name, slot)\n upsertRegistryFromSlot(slot)\n bootstrapStatus.set(entry.name, slot.status === 'broken' ? 'broken' : 'active')\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 bootstrapStatus.set(entry.name, 'broken')\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\n async function loadAll(): Promise<void> {\n const projects = await listProjects()\n const seen = new Set<string>()\n const pending: Promise<void>[] = []\n for (const entry of projects) {\n seen.add(entry.name)\n const existing = slots.get(entry.name)\n if (existing) {\n if (existing.status === 'broken') {\n pending.push(tryRecoverSlot(entry).then(() => {}))\n }\n continue\n }\n pending.push(bootstrapOne(entry))\n }\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 bootstrapStatus.delete(name)\n bootstrapStartedAt.delete(name)\n console.log(`neatd: project \"${name}\" removed from registry — stopped`)\n }\n await Promise.allSettled(pending)\n }\n\n // Issue #340 — pre-populate bootstrap status from the registry so the REST\n // listener can answer 503 for projects whose slot hasn't loaded yet. Actual\n // bootstrap moves to the background after `listen()` returns.\n const initialEntries = await listProjects().catch(() => [] as RegistryEntry[])\n for (const entry of initialEntries) {\n bootstrapStatus.set(entry.name, 'bootstrapping')\n bootstrapStartedAt.set(entry.name, Date.now())\n }\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 // ADR-073 §3 — fail-loud before binding. Loopback-only without a token is\n // fine (laptop dev); a public bind without one is not. Resolved here\n // ahead of the host so the loopback-default branch (issue #341) reads\n // the same token state the bind-authority gate does.\n const auth = readAuthEnv()\n const host = resolveHost(opts, Boolean(auth.authToken))\n const restPort = resolveRestPort(opts)\n const otlpPort = resolveOtlpPort(opts)\n\n assertBindAuthority(host, auth.authToken)\n\n try {\n restApp = await buildApi({\n projects: registry,\n authToken: auth.authToken,\n trustProxy: auth.trustProxy,\n publicRead: auth.publicRead,\n bootstrap: {\n status: (name) => bootstrapStatus.get(name),\n list: () => {\n const now = Date.now()\n return [...bootstrapStatus.entries()].map(([name, status]) => ({\n name,\n status,\n elapsedMs: now - (bootstrapStartedAt.get(name) ?? now),\n }))\n },\n },\n })\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. v0.4.1 / refs #339 — when nothing matches and\n // no default slot exists, the no-project-match event lands in\n // <NEAT_HOME>/errors.ndjson before we return null.\n async function resolveTargetSlot(\n serviceName: string | undefined,\n traceId: 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) {\n await recordUnroutedSpan(serviceName, traceId)\n return null\n }\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 authToken: auth.otelToken,\n trustProxy: auth.trustProxy,\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); when DEFAULT_PROJECT isn't registered either,\n // resolveTargetSlot writes a no-project-match event to\n // <NEAT_HOME>/errors.ndjson (refs #339).\n const slot = await resolveTargetSlot(span.service, span.traceId)\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, span.traceId)\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 // Issue #340 — listeners are live; kick off per-project bootstrap in the\n // background. Polled callers watch /health for transitions.\n const initialBootstrap = loadAll().catch((err) => {\n console.warn(`neatd: initial bootstrap pass failed — ${(err as Error).message}`)\n })\n\n let reloading: Promise<void> | null = initialBootstrap\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 void initialBootstrap.finally(() => {\n if (reloading === initialBootstrap) reloading = null\n })\n\n const tracker: BootstrapTracker = {\n status: (name) => bootstrapStatus.get(name),\n list: () => {\n const now = Date.now()\n return [...bootstrapStatus.entries()].map(([name, status]) => ({\n name,\n status,\n elapsedMs: now - (bootstrapStartedAt.get(name) ?? now),\n }))\n },\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 {\n slots,\n reload,\n stop,\n pidPath,\n restAddress,\n otlpAddress,\n bootstrap: tracker,\n initialBootstrap,\n }\n}\n","/**\n * Unrouted-span logging (v0.4.1 — refs #339).\n *\n * When the daemon's routing layer can't deliver a span — service.name\n * matches no registered project AND no `default` project exists — we still\n * return 200 on the receiver (OTLP spec), but the dropped event lands here\n * so the next operator can see what happened. The log lives at\n * `<NEAT_HOME>/errors.ndjson` because the unrouted span doesn't belong to\n * any project's neat-out directory.\n *\n * Owner: this module. Daemon imports the appender + warner; no other code\n * writes to the no-project-match log path. Keeping the writes here keeps\n * daemon.ts free of direct `fs.appendFile` calls (asserted by the daemon\n * contract test).\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\n\nexport interface UnroutedSpanRecord {\n timestamp: string\n reason: 'no-project-match'\n service_name: string | null\n traceId: string | null\n}\n\nexport function buildUnroutedSpanRecord(\n serviceName: string | undefined,\n traceId: string | undefined,\n now: Date = new Date(),\n): UnroutedSpanRecord {\n return {\n timestamp: now.toISOString(),\n reason: 'no-project-match',\n service_name: serviceName ?? null,\n traceId: traceId ?? null,\n }\n}\n\nexport async function appendUnroutedSpan(\n neatHome: string,\n record: UnroutedSpanRecord,\n): Promise<void> {\n const target = path.join(neatHome, 'errors.ndjson')\n await fs.mkdir(neatHome, { recursive: true })\n await fs.appendFile(target, JSON.stringify(record) + '\\n', 'utf8')\n}\n\nexport function unroutedErrorsPath(neatHome: string): string {\n return path.join(neatHome, 'errors.ndjson')\n}\n"],"mappings":"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","names":["fs","path","path","fs"]}
|
|
1
|
+
{"version":3,"sources":["../src/daemon.ts","../src/unrouted.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 { assertBindAuthority, readAuthEnv } from './auth.js'\nimport {\n appendUnroutedSpan,\n buildUnroutedSpanRecord,\n unroutedErrorsPath,\n} from './unrouted.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\n// Issue #340 — per-project bootstrap state surface. The REST listener\n// flips to live the moment `app.listen()` returns; per-project routes\n// branch on this rather than waiting for every registered project's\n// extractFromDirectory pass to finish.\nexport type BootstrapPhase = 'bootstrapping' | 'active' | 'broken'\n\nexport interface BootstrapTracker {\n status: (name: string) => BootstrapPhase | undefined\n list: () => Array<{ name: string; status: BootstrapPhase; elapsedMs: number }>\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 // Issue #340 — per-project bootstrap status, surfaced for orchestrator\n // poll loops and tests.\n bootstrap: BootstrapTracker\n // Resolves when the daemon's initial bootstrap pass has settled. Tests\n // that probe project-scoped routes immediately after startDaemon await\n // this; production callers use /health.\n initialBootstrap: Promise<void>\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, authTokenSet: boolean): 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 // Issue #341 — loopback-only default when the operator hasn't set a token.\n // Public-bind on a clean install demanded one before binding could\n // succeed, so the npx-`neat .` first-touch path used to refuse to come up;\n // pinning to 127.0.0.1 lets that path bind cleanly. Anyone wanting a\n // public bind sets `NEAT_AUTH_TOKEN` (and `HOST=0.0.0.0` if they want it\n // spelled out). `assertBindAuthority` stays exactly as it is — the\n // contract is right; the default was wrong.\n if (!authTokenSet) return '127.0.0.1'\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 // Issue #340 — per-project bootstrap status. Populated from the registry\n // before the listener binds so the REST handlers can return 503 instead of\n // 404 for projects still extracting.\n const bootstrapStatus = new Map<string, BootstrapPhase>()\n const bootstrapStartedAt = new Map<string, number>()\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 // v0.4.1 / refs #339 — when a span's `service.name` doesn't match any\n // registered project AND no `default` project is registered, the span has\n // nowhere to land. We still return 200 on the receiver (OTel spec) but the\n // event lands in <NEAT_HOME>/errors.ndjson so the next operator can see\n // what happened instead of the daemon's stderr being the only signal.\n // Same rate limit as the broken-project warning, keyed by service.name.\n const unroutedPath = unroutedErrorsPath(home)\n const lastUnroutedWarnAt = new Map<string, number>()\n async function recordUnroutedSpan(\n serviceName: string | undefined,\n traceId: string | undefined,\n ): Promise<void> {\n const key = serviceName ?? '<missing>'\n const now = Date.now()\n try {\n await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)))\n } catch {\n // best-effort — failing to log shouldn't cascade into receiver failure.\n }\n const prev = lastUnroutedWarnAt.get(key) ?? 0\n if (now - prev < DROP_WARN_INTERVAL_MS) return\n lastUnroutedWarnAt.set(key, now)\n console.warn(\n `[neatd] dropping span — service.name \"${key}\" matches no registered project and no \\`default\\` project exists. See ${unroutedPath}.`,\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 bootstrapOne(entry: RegistryEntry): Promise<void> {\n bootstrapStatus.set(entry.name, 'bootstrapping')\n bootstrapStartedAt.set(entry.name, Date.now())\n try {\n const slot = await bootstrapProject(entry)\n slots.set(entry.name, slot)\n upsertRegistryFromSlot(slot)\n bootstrapStatus.set(entry.name, slot.status === 'broken' ? 'broken' : 'active')\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 bootstrapStatus.set(entry.name, 'broken')\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\n async function loadAll(): Promise<void> {\n const projects = await listProjects()\n const seen = new Set<string>()\n const pending: Promise<void>[] = []\n for (const entry of projects) {\n seen.add(entry.name)\n const existing = slots.get(entry.name)\n if (existing) {\n if (existing.status === 'broken') {\n pending.push(tryRecoverSlot(entry).then(() => {}))\n }\n continue\n }\n pending.push(bootstrapOne(entry))\n }\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 bootstrapStatus.delete(name)\n bootstrapStartedAt.delete(name)\n console.log(`neatd: project \"${name}\" removed from registry — stopped`)\n }\n await Promise.allSettled(pending)\n }\n\n // Issue #340 — pre-populate bootstrap status from the registry so the REST\n // listener can answer 503 for projects whose slot hasn't loaded yet. Actual\n // bootstrap moves to the background after `listen()` returns.\n const initialEntries = await listProjects().catch(() => [] as RegistryEntry[])\n for (const entry of initialEntries) {\n bootstrapStatus.set(entry.name, 'bootstrapping')\n bootstrapStartedAt.set(entry.name, Date.now())\n }\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 // ADR-073 §3 — fail-loud before binding. Loopback-only without a token is\n // fine (laptop dev); a public bind without one is not. Resolved here\n // ahead of the host so the loopback-default branch (issue #341) reads\n // the same token state the bind-authority gate does.\n const auth = readAuthEnv()\n const host = resolveHost(opts, Boolean(auth.authToken))\n const restPort = resolveRestPort(opts)\n const otlpPort = resolveOtlpPort(opts)\n\n assertBindAuthority(host, auth.authToken)\n\n try {\n restApp = await buildApi({\n projects: registry,\n authToken: auth.authToken,\n trustProxy: auth.trustProxy,\n publicRead: auth.publicRead,\n bootstrap: {\n status: (name) => bootstrapStatus.get(name),\n list: () => {\n const now = Date.now()\n return [...bootstrapStatus.entries()].map(([name, status]) => ({\n name,\n status,\n elapsedMs: now - (bootstrapStartedAt.get(name) ?? now),\n }))\n },\n },\n })\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. v0.4.1 / refs #339 — when nothing matches and\n // no default slot exists, the no-project-match event lands in\n // <NEAT_HOME>/errors.ndjson before we return null.\n async function resolveTargetSlot(\n serviceName: string | undefined,\n traceId: 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) {\n await recordUnroutedSpan(serviceName, traceId)\n return null\n }\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 // Resolve a project slot by its registered name — the path the\n // project-scoped OTLP route (issue #367) takes. URL-extracted project\n // names sidestep the service.name heuristic; we still want the broken\n // -slot recovery + unrouted-span logging so the route's failure modes\n // match the legacy path's.\n async function resolveSlotByName(\n project: string,\n serviceName: string | undefined,\n traceId: string | undefined,\n ): Promise<ProjectSlot | null> {\n const liveEntries = await listProjects().catch(() => [])\n let slot = slots.get(project)\n if (!slot) {\n await recordUnroutedSpan(serviceName, traceId)\n return null\n }\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 authToken: auth.otelToken,\n trustProxy: auth.trustProxy,\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); when DEFAULT_PROJECT isn't registered either,\n // resolveTargetSlot writes a no-project-match event to\n // <NEAT_HOME>/errors.ndjson (refs #339).\n const slot = await resolveTargetSlot(span.service, span.traceId)\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, span.traceId)\n if (!slot) return\n await makeErrorSpanWriter(slot.paths.errorsPath)(span)\n },\n // Project-scoped route (issue #367) — the URL already named the\n // project. Resolution is a direct slot lookup; service.name resolves\n // the ServiceNode inside the slot's graph instead of which project\n // owns the span.\n onProjectSpan: async (project, span) => {\n const slot = await resolveSlotByName(project, span.service, span.traceId)\n if (!slot) return\n await handleSpan(\n {\n graph: slot.graph,\n errorsPath: slot.paths.errorsPath,\n project: slot.entry.name,\n writeErrorEventInline: false,\n },\n span,\n )\n },\n onProjectErrorSpanSync: async (project, span) => {\n const slot = await resolveSlotByName(project, span.service, span.traceId)\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 // Issue #340 — listeners are live; kick off per-project bootstrap in the\n // background. Polled callers watch /health for transitions.\n const initialBootstrap = loadAll().catch((err) => {\n console.warn(`neatd: initial bootstrap pass failed — ${(err as Error).message}`)\n })\n\n let reloading: Promise<void> | null = initialBootstrap\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 void initialBootstrap.finally(() => {\n if (reloading === initialBootstrap) reloading = null\n })\n\n const tracker: BootstrapTracker = {\n status: (name) => bootstrapStatus.get(name),\n list: () => {\n const now = Date.now()\n return [...bootstrapStatus.entries()].map(([name, status]) => ({\n name,\n status,\n elapsedMs: now - (bootstrapStartedAt.get(name) ?? now),\n }))\n },\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 {\n slots,\n reload,\n stop,\n pidPath,\n restAddress,\n otlpAddress,\n bootstrap: tracker,\n initialBootstrap,\n }\n}\n","/**\n * Unrouted-span logging (v0.4.1 — refs #339).\n *\n * When the daemon's routing layer can't deliver a span — service.name\n * matches no registered project AND no `default` project exists — we still\n * return 200 on the receiver (OTLP spec), but the dropped event lands here\n * so the next operator can see what happened. The log lives at\n * `<NEAT_HOME>/errors.ndjson` because the unrouted span doesn't belong to\n * any project's neat-out directory.\n *\n * Owner: this module. Daemon imports the appender + warner; no other code\n * writes to the no-project-match log path. Keeping the writes here keeps\n * daemon.ts free of direct `fs.appendFile` calls (asserted by the daemon\n * contract test).\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\n\nexport interface UnroutedSpanRecord {\n timestamp: string\n reason: 'no-project-match'\n service_name: string | null\n traceId: string | null\n}\n\nexport function buildUnroutedSpanRecord(\n serviceName: string | undefined,\n traceId: string | undefined,\n now: Date = new Date(),\n): UnroutedSpanRecord {\n return {\n timestamp: now.toISOString(),\n reason: 'no-project-match',\n service_name: serviceName ?? null,\n traceId: traceId ?? null,\n }\n}\n\nexport async function appendUnroutedSpan(\n neatHome: string,\n record: UnroutedSpanRecord,\n): Promise<void> {\n const target = path.join(neatHome, 'errors.ndjson')\n await fs.mkdir(neatHome, { recursive: true })\n await fs.appendFile(target, JSON.stringify(record) + '\\n', 'utf8')\n}\n\nexport function unroutedErrorsPath(neatHome: string): string {\n return path.join(neatHome, 'errors.ndjson')\n}\n"],"mappings":"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","names":["fs","path","path","fs"]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseOtlpRequest
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ZVNP3ZDH.js";
|
|
4
4
|
|
|
5
5
|
// src/otel-grpc.ts
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
@@ -136,4 +136,4 @@ export {
|
|
|
136
136
|
reshapeGrpcRequest,
|
|
137
137
|
startOtelGrpcReceiver
|
|
138
138
|
};
|
|
139
|
-
//# sourceMappingURL=chunk-
|
|
139
|
+
//# sourceMappingURL=chunk-YJOA7BBF.js.map
|
|
@@ -236,7 +236,7 @@ function encodeProtobufResponseBody() {
|
|
|
236
236
|
async function decodeProtobufBody(buf) {
|
|
237
237
|
const Type = loadProtobufDecoder();
|
|
238
238
|
const decoded = Type.decode(buf).toJSON();
|
|
239
|
-
const { reshapeGrpcRequest } = await import("./otel-grpc-
|
|
239
|
+
const { reshapeGrpcRequest } = await import("./otel-grpc-FIERFRGD.js");
|
|
240
240
|
return reshapeGrpcRequest(decoded);
|
|
241
241
|
}
|
|
242
242
|
async function buildOtelReceiver(opts) {
|
|
@@ -269,6 +269,64 @@ async function buildOtelReceiver(opts) {
|
|
|
269
269
|
for (const s of spans) queue.push(s);
|
|
270
270
|
drainPromise = drainPromise.then(() => drain());
|
|
271
271
|
};
|
|
272
|
+
const projectQueue = [];
|
|
273
|
+
let projectDraining = false;
|
|
274
|
+
let projectDrainPromise = Promise.resolve();
|
|
275
|
+
const drainProject = async () => {
|
|
276
|
+
if (projectDraining) return;
|
|
277
|
+
projectDraining = true;
|
|
278
|
+
try {
|
|
279
|
+
while (projectQueue.length > 0) {
|
|
280
|
+
const { project, span } = projectQueue.shift();
|
|
281
|
+
try {
|
|
282
|
+
if (opts.onProjectSpan) {
|
|
283
|
+
await opts.onProjectSpan(project, span);
|
|
284
|
+
} else {
|
|
285
|
+
await opts.onSpan(span);
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.warn(`[neat] otel handler error: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
projectDraining = false;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const enqueueProject = (project, spans) => {
|
|
296
|
+
if (spans.length === 0) return;
|
|
297
|
+
for (const s of spans) projectQueue.push({ project, span: s });
|
|
298
|
+
projectDrainPromise = projectDrainPromise.then(() => drainProject());
|
|
299
|
+
};
|
|
300
|
+
const legacyEndpointWarned = /* @__PURE__ */ new Set();
|
|
301
|
+
function warnLegacyEndpoint(serviceName) {
|
|
302
|
+
if (legacyEndpointWarned.has(serviceName)) return;
|
|
303
|
+
legacyEndpointWarned.add(serviceName);
|
|
304
|
+
console.warn(
|
|
305
|
+
`[neatd] received span on the global endpoint; migrate OTEL_EXPORTER_OTLP_TRACES_ENDPOINT to /projects/<name>/v1/traces (service.name="${serviceName}").`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
async function readOtlpBody(req) {
|
|
309
|
+
const ct = (req.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
|
|
310
|
+
if (ct === "application/x-protobuf") {
|
|
311
|
+
try {
|
|
312
|
+
const body = await decodeProtobufBody(req.body);
|
|
313
|
+
return { ok: true, body, flavor: "protobuf" };
|
|
314
|
+
} catch (err) {
|
|
315
|
+
return { ok: false, code: 400, error: `protobuf decode failed: ${err.message}` };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!ct || ct === "application/json") {
|
|
319
|
+
return { ok: true, body: req.body ?? {}, flavor: "json" };
|
|
320
|
+
}
|
|
321
|
+
return { ok: false, code: 415, error: `unsupported content-type: ${ct}` };
|
|
322
|
+
}
|
|
323
|
+
function sendOtlpSuccess(reply, flavor) {
|
|
324
|
+
if (flavor === "protobuf") {
|
|
325
|
+
const buf = encodeProtobufResponseBody();
|
|
326
|
+
return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
|
|
327
|
+
}
|
|
328
|
+
return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
|
|
329
|
+
}
|
|
272
330
|
app.addContentTypeParser(
|
|
273
331
|
"application/x-protobuf",
|
|
274
332
|
{ parseAs: "buffer", bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024 },
|
|
@@ -278,26 +336,44 @@ async function buildOtelReceiver(opts) {
|
|
|
278
336
|
);
|
|
279
337
|
app.get("/health", async () => ({ ok: true }));
|
|
280
338
|
app.post("/v1/traces", async (req, reply) => {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
339
|
+
const result = await readOtlpBody(req);
|
|
340
|
+
if (!result.ok) {
|
|
341
|
+
return reply.code(result.code).send({ error: result.error });
|
|
342
|
+
}
|
|
343
|
+
const spans = parseOtlpRequest(result.body);
|
|
344
|
+
for (const s of spans) warnLegacyEndpoint(s.service);
|
|
345
|
+
if (opts.onErrorSpanSync) {
|
|
286
346
|
try {
|
|
287
|
-
|
|
347
|
+
for (const span of spans) {
|
|
348
|
+
if (span.statusCode === 2) await opts.onErrorSpanSync(span);
|
|
349
|
+
}
|
|
288
350
|
} catch (err) {
|
|
289
|
-
return reply.code(
|
|
290
|
-
error: `
|
|
351
|
+
return reply.code(500).send({
|
|
352
|
+
error: `error-event write failed: ${err.message}`
|
|
291
353
|
});
|
|
292
354
|
}
|
|
293
|
-
} else if (!ct || ct === "application/json") {
|
|
294
|
-
responseFlavor = "json";
|
|
295
|
-
body = req.body ?? {};
|
|
296
|
-
} else {
|
|
297
|
-
return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
|
|
298
355
|
}
|
|
299
|
-
|
|
300
|
-
|
|
356
|
+
enqueue(spans);
|
|
357
|
+
return sendOtlpSuccess(reply, result.flavor);
|
|
358
|
+
});
|
|
359
|
+
app.post("/projects/:project/v1/traces", async (req, reply) => {
|
|
360
|
+
const project = req.params.project;
|
|
361
|
+
const result = await readOtlpBody(req);
|
|
362
|
+
if (!result.ok) {
|
|
363
|
+
return reply.code(result.code).send({ error: result.error });
|
|
364
|
+
}
|
|
365
|
+
const spans = parseOtlpRequest(result.body);
|
|
366
|
+
if (opts.onProjectErrorSpanSync) {
|
|
367
|
+
try {
|
|
368
|
+
for (const span of spans) {
|
|
369
|
+
if (span.statusCode === 2) await opts.onProjectErrorSpanSync(project, span);
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return reply.code(500).send({
|
|
373
|
+
error: `error-event write failed: ${err.message}`
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
} else if (opts.onErrorSpanSync) {
|
|
301
377
|
try {
|
|
302
378
|
for (const span of spans) {
|
|
303
379
|
if (span.statusCode === 2) await opts.onErrorSpanSync(span);
|
|
@@ -308,17 +384,13 @@ async function buildOtelReceiver(opts) {
|
|
|
308
384
|
});
|
|
309
385
|
}
|
|
310
386
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const buf = encodeProtobufResponseBody();
|
|
314
|
-
return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
|
|
315
|
-
}
|
|
316
|
-
return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
|
|
387
|
+
enqueueProject(project, spans);
|
|
388
|
+
return sendOtlpSuccess(reply, result.flavor);
|
|
317
389
|
});
|
|
318
390
|
const decorated = app;
|
|
319
391
|
decorated.flushPending = async () => {
|
|
320
|
-
while (queue.length > 0 || draining) {
|
|
321
|
-
await drainPromise;
|
|
392
|
+
while (queue.length > 0 || draining || projectQueue.length > 0 || projectDraining) {
|
|
393
|
+
await Promise.all([drainPromise, projectDrainPromise]);
|
|
322
394
|
}
|
|
323
395
|
};
|
|
324
396
|
return decorated;
|
|
@@ -343,4 +415,4 @@ export {
|
|
|
343
415
|
buildOtelReceiver,
|
|
344
416
|
logSpanHandler
|
|
345
417
|
};
|
|
346
|
-
//# sourceMappingURL=chunk-
|
|
418
|
+
//# sourceMappingURL=chunk-ZVNP3ZDH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../node_modules/tsup/assets/esm_shims.js","../src/auth.ts","../src/otel.ts"],"sourcesContent":["// Shim globals in esm bundle\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst getFilename = () => fileURLToPath(import.meta.url)\nconst getDirname = () => path.dirname(getFilename())\n\nexport const __dirname = /* @__PURE__ */ getDirname()\nexport const __filename = /* @__PURE__ */ getFilename()\n","/**\n * Delegated auth at the daemon boundary (ADR-073 §3 + §4).\n *\n * NEAT does not issue, rotate, or distribute the token — that is the deploy\n * platform's job. This module provides the two surfaces the daemon needs:\n *\n * - `assertBindAuthority(host, token)` — fail-loud pre-bind check. When no\n * token is set, the daemon refuses to bind on any non-loopback address.\n * Loopback-only without a token stays unauthenticated (laptop dev path).\n *\n * - `mountBearerAuth(app, opts)` — Fastify `preHandler` that requires\n * `Authorization: Bearer <token>` on every request other than the\n * unauthenticated health/readiness probes. Constant-time comparison.\n *\n * The same shape covers both the REST host and the OTLP receivers; the OTLP\n * side passes a different token (`NEAT_OTEL_TOKEN ?? NEAT_AUTH_TOKEN`) so the\n * two surfaces rotate independently.\n */\n\nimport { timingSafeEqual } from 'node:crypto'\nimport type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'\n\n// Hosts that count as loopback for the bind-authority gate. `0.0.0.0` is\n// explicitly not loopback — it binds on every interface, including any\n// public one the operator's box happens to have.\nconst LOOPBACK_HOSTS: ReadonlySet<string> = new Set([\n '127.0.0.1',\n 'localhost',\n '::1',\n '::ffff:127.0.0.1',\n])\n\nexport function isLoopbackHost(host: string | undefined | null): boolean {\n if (!host) return false\n return LOOPBACK_HOSTS.has(host)\n}\n\nexport class BindAuthorityError extends Error {\n constructor(host: string) {\n super(\n `NEAT refuses to bind on a public interface without \\`NEAT_AUTH_TOKEN\\` set (host=\"${host}\"). Set the token or bind to loopback only.`,\n )\n this.name = 'BindAuthorityError'\n }\n}\n\nexport function assertBindAuthority(host: string, token: string | undefined): void {\n if (token && token.length > 0) return\n if (isLoopbackHost(host)) return\n throw new BindAuthorityError(host)\n}\n\nexport interface AuthOptions {\n // Bearer token required on every protected route. Undefined / empty → the\n // middleware is not mounted and the route stays unauthenticated. The\n // loopback-only gate above is what keeps that case from being public.\n token?: string\n // When `true`, trust an upstream reverse proxy and skip the request-side\n // check. The fail-loud bind-authority gate still applies upstream. Wired\n // to `NEAT_AUTH_PROXY=true` in production.\n trustProxy?: boolean\n // ADR-073 §3 amendment — public-read mode for reference deployments.\n // When `true`, GET / HEAD / OPTIONS pass through without a bearer; every\n // other verb still requires the token. OTLP ingest is excluded — that\n // surface stays gated unconditionally (the receiver mounts its own\n // middleware without this flag).\n publicRead?: boolean\n // Extra paths (or path suffixes) to leave unauthenticated. Used by tests\n // and by ad-hoc callers that mount their own probes.\n extraUnauthenticatedSuffixes?: ReadonlyArray<string>\n}\n\n// Verbs the public-read split treats as reads. Everything else is a write\n// and keeps the bearer requirement.\nconst PUBLIC_READ_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'OPTIONS'])\n\n// Probes that always stay open. Dual-mounted under `/projects/:project/` too,\n// so the check is a suffix match — `/projects/foo/health` is skipped along\n// with the top-level `/health`. ADR-073 §3 names `/healthz` and `/readyz`\n// explicitly; `/health` is the existing endpoint the web shell and CI smoke\n// already lean on, so it keeps the unauthenticated treatment. `/api/config`\n// is the public-read negotiation endpoint — the web shell hits it before any\n// authed call to learn which mode the daemon is running in.\nconst DEFAULT_UNAUTH_SUFFIXES: ReadonlyArray<string> = [\n '/health',\n '/healthz',\n '/readyz',\n '/api/config',\n]\n\nexport function mountBearerAuth(app: FastifyInstance, opts: AuthOptions): void {\n if (!opts.token || opts.token.length === 0) return\n if (opts.trustProxy) return\n\n const expected = Buffer.from(opts.token, 'utf8')\n const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...(opts.extraUnauthenticatedSuffixes ?? [])]\n const publicRead = opts.publicRead === true\n\n app.addHook('preHandler', (req: FastifyRequest, reply: FastifyReply, done: (err?: Error) => void) => {\n const path = (req.url.split('?')[0] ?? '').replace(/\\/+$/, '')\n for (const suffix of suffixes) {\n if (path === suffix || path.endsWith(suffix)) {\n done()\n return\n }\n }\n\n // Public-read split: GET / HEAD / OPTIONS pass through anonymously, every\n // other verb keeps the bearer check. The token still authorizes writes,\n // and the bind-authority gate above still demands a token for non-loopback\n // binds — public-read enables anonymous reads on top of that, it doesn't\n // replace either invariant.\n if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {\n done()\n return\n }\n\n const header = req.headers.authorization\n if (typeof header !== 'string' || !header.startsWith('Bearer ')) {\n void reply.code(401).send({ error: 'unauthorized' })\n return\n }\n const provided = Buffer.from(header.slice('Bearer '.length).trim(), 'utf8')\n if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {\n void reply.code(401).send({ error: 'unauthorized' })\n return\n }\n done()\n })\n}\n\n// Read both tokens from the environment in one place so server.ts, daemon.ts,\n// and the OTel receivers all agree on precedence (ADR-073 §4). `publicRead`\n// rides the same shape so callers don't need a second env read.\nexport interface AuthEnv {\n authToken: string | undefined\n otelToken: string | undefined\n trustProxy: boolean\n publicRead: boolean\n}\n\nfunction parseBoolEnv(v: string | undefined): boolean {\n if (!v) return false\n return v === 'true' || v === '1'\n}\n\nexport function readAuthEnv(env: NodeJS.ProcessEnv = process.env): AuthEnv {\n const t = env.NEAT_AUTH_TOKEN\n const ot = env.NEAT_OTEL_TOKEN\n return {\n authToken: t && t.length > 0 ? t : undefined,\n otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : undefined,\n trustProxy: env.NEAT_AUTH_PROXY === 'true',\n publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ),\n }\n}\n","import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport Fastify, { type FastifyInstance } from 'fastify'\nimport protobuf from 'protobufjs'\nimport { mountBearerAuth } from './auth.js'\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 // Deployment environment from `deployment.environment(.name)`. Span attrs\n // win over resource attrs (per-span overrides a resource-wide declaration);\n // the canonical `deployment.environment.name` (OTel SC v1.27+) wins over\n // the compat form `deployment.environment`. Literal `'unknown'` is the\n // honest fallback when no env signal is present anywhere on the span or\n // its resource. See ADR-074 §2 / docs/contracts/env-dimension.md.\n env: string\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// Variant that receives the project the URL already resolved to. Used by the\n// project-scoped route mounted at `/projects/:project/v1/traces` (issue #367):\n// the receiver hands the URL-path project to the daemon directly so the\n// daemon never has to guess via `service.name`-against-registry matching.\nexport type ProjectSpanHandler = (project: string, 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 // Project-scoped variants — used by the `/projects/:project/v1/traces`\n // route. When unset the route is still mounted but falls through to the\n // legacy `onSpan` / `onErrorSpanSync` handlers (the project name is then\n // available to the consumer via the `OTEL_PROJECT_OVERRIDE` attribute on\n // each parsed span; ad-hoc receivers commonly leave both unset).\n onProjectSpan?: ProjectSpanHandler\n onProjectErrorSpanSync?: (project: string, span: ParsedSpan) => Promise<void>\n // Fastify body limit. OTLP batches can be large; default is 16 MB.\n bodyLimit?: number\n // ADR-073 §4 — bearer required on `/v1/traces`. Defaults to `NEAT_AUTH_TOKEN`\n // when unset; `NEAT_OTEL_TOKEN` overrides at the call site so the REST and\n // OTLP surfaces rotate on independent schedules.\n authToken?: string\n // Same shape as the REST middleware: skip the request-side check when an\n // upstream reverse proxy already authenticated.\n trustProxy?: boolean\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\n// Resolve `deployment.environment` per ADR-074 §2. The four-step fallback\n// is: span-attr canonical form → span-attr compat form → resource-attr\n// canonical form → resource-attr compat form → literal `'unknown'`. The\n// literal `'unknown'` is the honest sentinel; defaulting to `'production'`\n// or `'development'` would bake an incorrect assumption into every span\n// from a workload that hasn't yet wired its env signal.\nconst ENV_ATTR_CANONICAL = 'deployment.environment.name'\nconst ENV_ATTR_COMPAT = 'deployment.environment'\nconst ENV_FALLBACK = 'unknown'\n\nfunction pickEnv(\n spanAttrs: Record<string, AttributeValue>,\n resourceAttrs: Record<string, AttributeValue>,\n): string {\n for (const attrs of [spanAttrs, resourceAttrs]) {\n for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {\n const v = attrs[key]\n if (typeof v === 'string' && v.length > 0) return v\n }\n }\n return ENV_FALLBACK\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 env: pickEnv(attrs, resourceAttrs),\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 // ADR-073 §4 — bearer on `/v1/traces`. `/health` stays unauthenticated via\n // the default suffix list (the CI smoke and supervisors lean on it for\n // liveness probes).\n mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy })\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 // Per-project queue is reusable across the project-scoped route — the\n // project name rides in the URL, not on the span. Drain semantics mirror\n // the global queue so flushPending() captures both.\n const projectQueue: Array<{ project: string; span: ParsedSpan }> = []\n let projectDraining = false\n let projectDrainPromise: Promise<void> = Promise.resolve()\n const drainProject = async (): Promise<void> => {\n if (projectDraining) return\n projectDraining = true\n try {\n while (projectQueue.length > 0) {\n const { project, span } = projectQueue.shift()!\n try {\n if (opts.onProjectSpan) {\n await opts.onProjectSpan(project, span)\n } else {\n await opts.onSpan(span)\n }\n } catch (err) {\n console.warn(`[neat] otel handler error: ${(err as Error).message}`)\n }\n }\n } finally {\n projectDraining = false\n }\n }\n const enqueueProject = (project: string, spans: ParsedSpan[]): void => {\n if (spans.length === 0) return\n for (const s of spans) projectQueue.push({ project, span: s })\n projectDrainPromise = projectDrainPromise.then(() => drainProject())\n }\n\n // One-time-per-service-name deprecation warning for spans landing on the\n // legacy global endpoint. The replacement is the project-scoped URL\n // (`/projects/<project>/v1/traces`) the installer writes into `.env.neat`\n // from v0.4.4. The warning is once-per-name so a long-running daemon\n // doesn't flood stderr while an operator is migrating.\n const legacyEndpointWarned = new Set<string>()\n function warnLegacyEndpoint(serviceName: string): void {\n if (legacyEndpointWarned.has(serviceName)) return\n legacyEndpointWarned.add(serviceName)\n console.warn(\n `[neatd] received span on the global endpoint; migrate OTEL_EXPORTER_OTLP_TRACES_ENDPOINT to /projects/<name>/v1/traces (service.name=\"${serviceName}\").`,\n )\n }\n\n // Shared body-decode + content-negotiation. Both `/v1/traces` and\n // `/projects/:project/v1/traces` go through this so the protobuf/JSON\n // dispatch stays in one place.\n async function readOtlpBody(req: import('fastify').FastifyRequest): Promise<\n | { ok: true; body: OtlpTracesRequest; flavor: 'json' | 'protobuf' }\n | { ok: false; code: 400 | 415; error: string }\n > {\n const ct = (req.headers['content-type'] ?? '').toString().split(';')[0]!.trim().toLowerCase()\n if (ct === 'application/x-protobuf') {\n try {\n const body = await decodeProtobufBody(req.body as Buffer)\n return { ok: true, body, flavor: 'protobuf' }\n } catch (err) {\n return { ok: false, code: 400, error: `protobuf decode failed: ${(err as Error).message}` }\n }\n }\n if (!ct || ct === 'application/json') {\n return { ok: true, body: (req.body ?? {}) as OtlpTracesRequest, flavor: 'json' }\n }\n return { ok: false, code: 415, error: `unsupported content-type: ${ct}` }\n }\n\n function sendOtlpSuccess(reply: import('fastify').FastifyReply, flavor: 'json' | 'protobuf'): unknown {\n if (flavor === '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 // 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 // Legacy global endpoint. Spans land here when an OTel exporter hasn't\n // migrated to the project-scoped URL yet (issue #367); the daemon still\n // routes by `service.name` against the registry. Per-service-name\n // deprecation warning fires once so an operator notices without their\n // stderr flooding.\n const result = await readOtlpBody(req)\n if (!result.ok) {\n return reply.code(result.code).send({ error: result.error })\n }\n const spans = parseOtlpRequest(result.body)\n for (const s of spans) warnLegacyEndpoint(s.service)\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 return sendOtlpSuccess(reply, result.flavor)\n })\n\n // Project-scoped route (issue #367). The URL `:project` carries the routing\n // key, sidestepping the `service.name`-against-registry heuristic the legacy\n // path uses. Spans get dispatched into the named project's ingest path\n // directly; `OTEL_SERVICE_NAME` regains its proper semantic role of naming\n // the ServiceNode inside the one project the URL already picked.\n app.post<{ Params: { project: string } }>('/projects/:project/v1/traces', async (req, reply) => {\n const project = req.params.project\n const result = await readOtlpBody(req)\n if (!result.ok) {\n return reply.code(result.code).send({ error: result.error })\n }\n const spans = parseOtlpRequest(result.body)\n if (opts.onProjectErrorSpanSync) {\n try {\n for (const span of spans) {\n if (span.statusCode === 2) await opts.onProjectErrorSpanSync(project, span)\n }\n } catch (err) {\n return reply.code(500).send({\n error: `error-event write failed: ${(err as Error).message}`,\n })\n }\n } else 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 enqueueProject(project, spans)\n return sendOtlpSuccess(reply, result.flavor)\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 both drain chains, then loop until both queues are fully empty\n // (a span enqueued mid-flush would otherwise be missed).\n while (queue.length > 0 || draining || projectQueue.length > 0 || projectDraining) {\n await Promise.all([drainPromise, projectDrainPromise])\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":["path","path","fileURLToPath","path","fileURLToPath"]}
|