@neat.is/core 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -116,6 +116,7 @@ interface BuildApiOptions {
116
116
  searchIndex?: SearchIndex;
117
117
  authToken?: string;
118
118
  trustProxy?: boolean;
119
+ publicRead?: boolean;
119
120
  }
120
121
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
121
122
 
@@ -130,6 +131,7 @@ interface ParsedSpan {
130
131
  endTimeUnixNano: string;
131
132
  startTimeIso?: string;
132
133
  durationNanos: bigint;
134
+ env: string;
133
135
  attributes: Record<string, AttributeValue>;
134
136
  dbSystem?: string;
135
137
  dbName?: string;
package/dist/index.d.ts CHANGED
@@ -116,6 +116,7 @@ interface BuildApiOptions {
116
116
  searchIndex?: SearchIndex;
117
117
  authToken?: string;
118
118
  trustProxy?: boolean;
119
+ publicRead?: boolean;
119
120
  }
120
121
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
121
122
 
@@ -130,6 +131,7 @@ interface ParsedSpan {
130
131
  endTimeUnixNano: string;
131
132
  startTimeIso?: string;
132
133
  durationNanos: bigint;
134
+ env: string;
133
135
  attributes: Record<string, AttributeValue>;
134
136
  dbSystem?: string;
135
137
  dbName?: string;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-LQ3JFBTX.js";
4
+ } from "./chunk-N6RPINEJ.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -37,15 +37,15 @@ import {
37
37
  thresholdForEdgeType,
38
38
  touchLastSeen,
39
39
  writeAtomically
40
- } from "./chunk-CZ3T6TE2.js";
40
+ } from "./chunk-UPW4CMOH.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
- } from "./chunk-V4TU7OKZ.js";
43
+ } from "./chunk-IXIFJKMM.js";
44
44
  import {
45
45
  buildOtelReceiver,
46
46
  logSpanHandler,
47
47
  parseOtlpRequest
48
- } from "./chunk-7TYESDAI.js";
48
+ } from "./chunk-4V23KYOP.js";
49
49
  export {
50
50
  ProjectNameCollisionError,
51
51
  addProject,
package/dist/neatd.cjs CHANGED
@@ -55,6 +55,7 @@ function mountBearerAuth(app, opts) {
55
55
  if (opts.trustProxy) return;
56
56
  const expected = Buffer.from(opts.token, "utf8");
57
57
  const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
58
+ const publicRead = opts.publicRead === true;
58
59
  app.addHook("preHandler", (req2, reply, done) => {
59
60
  const path39 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
61
  for (const suffix of suffixes) {
@@ -63,6 +64,10 @@ function mountBearerAuth(app, opts) {
63
64
  return;
64
65
  }
65
66
  }
67
+ if (publicRead && PUBLIC_READ_METHODS.has(req2.method)) {
68
+ done();
69
+ return;
70
+ }
66
71
  const header = req2.headers.authorization;
67
72
  if (typeof header !== "string" || !header.startsWith("Bearer ")) {
68
73
  void reply.code(401).send({ error: "unauthorized" });
@@ -76,16 +81,21 @@ function mountBearerAuth(app, opts) {
76
81
  done();
77
82
  });
78
83
  }
84
+ function parseBoolEnv(v) {
85
+ if (!v) return false;
86
+ return v === "true" || v === "1";
87
+ }
79
88
  function readAuthEnv(env = process.env) {
80
89
  const t = env.NEAT_AUTH_TOKEN;
81
90
  const ot = env.NEAT_OTEL_TOKEN;
82
91
  return {
83
92
  authToken: t && t.length > 0 ? t : void 0,
84
93
  otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
85
- trustProxy: env.NEAT_AUTH_PROXY === "true"
94
+ trustProxy: env.NEAT_AUTH_PROXY === "true",
95
+ publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
86
96
  };
87
97
  }
88
- var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
98
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
89
99
  var init_auth = __esm({
90
100
  "src/auth.ts"() {
91
101
  "use strict";
@@ -105,7 +115,13 @@ var init_auth = __esm({
105
115
  this.name = "BindAuthorityError";
106
116
  }
107
117
  };
108
- DEFAULT_UNAUTH_SUFFIXES = ["/health", "/healthz", "/readyz"];
118
+ PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
119
+ DEFAULT_UNAUTH_SUFFIXES = [
120
+ "/health",
121
+ "/healthz",
122
+ "/readyz",
123
+ "/api/config"
124
+ ];
109
125
  }
110
126
  });
111
127
 
@@ -308,6 +324,15 @@ function isoFromUnixNano(nanos) {
308
324
  return void 0;
309
325
  }
310
326
  }
327
+ function pickEnv(spanAttrs, resourceAttrs) {
328
+ for (const attrs of [spanAttrs, resourceAttrs]) {
329
+ for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
330
+ const v = attrs[key];
331
+ if (typeof v === "string" && v.length > 0) return v;
332
+ }
333
+ }
334
+ return ENV_FALLBACK;
335
+ }
311
336
  function parseOtlpRequest(body) {
312
337
  const out = [];
313
338
  for (const rs of body.resourceSpans ?? []) {
@@ -327,6 +352,7 @@ function parseOtlpRequest(body) {
327
352
  endTimeUnixNano: span.endTimeUnixNano ?? "0",
328
353
  startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
329
354
  durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
355
+ env: pickEnv(attrs, resourceAttrs),
330
356
  attributes: attrs,
331
357
  dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
332
358
  dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
@@ -465,7 +491,7 @@ async function buildOtelReceiver(opts) {
465
491
  };
466
492
  return decorated;
467
493
  }
468
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
494
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
469
495
  var init_otel = __esm({
470
496
  "src/otel.ts"() {
471
497
  "use strict";
@@ -475,6 +501,9 @@ var init_otel = __esm({
475
501
  import_fastify2 = __toESM(require("fastify"), 1);
476
502
  import_protobufjs = __toESM(require("protobufjs"), 1);
477
503
  init_auth();
504
+ ENV_ATTR_CANONICAL = "deployment.environment.name";
505
+ ENV_ATTR_COMPAT = "deployment.environment";
506
+ ENV_FALLBACK = "unknown";
478
507
  exportTraceServiceRequestType = null;
479
508
  exportTraceServiceResponseType = null;
480
509
  cachedProtobufResponseBody = null;
@@ -1513,52 +1542,64 @@ function cacheSpanService(span, now) {
1513
1542
  if (!span.traceId || !span.spanId) return;
1514
1543
  const key = parentSpanKey(span.traceId, span.spanId);
1515
1544
  parentSpanCache.delete(key);
1516
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1545
+ parentSpanCache.set(key, {
1546
+ service: span.service,
1547
+ env: span.env ?? "unknown",
1548
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1549
+ });
1517
1550
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1518
1551
  const oldest = parentSpanCache.keys().next().value;
1519
1552
  if (!oldest) break;
1520
1553
  parentSpanCache.delete(oldest);
1521
1554
  }
1522
1555
  }
1523
- function lookupParentSpanService(traceId, parentSpanId, now) {
1556
+ function lookupParentSpan(traceId, parentSpanId, now) {
1524
1557
  const entry2 = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1525
1558
  if (!entry2) return null;
1526
1559
  if (entry2.expiresAt <= now) {
1527
1560
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1528
1561
  return null;
1529
1562
  }
1530
- return entry2.service;
1563
+ return { service: entry2.service, env: entry2.env };
1531
1564
  }
1532
- function resolveServiceId(graph, host) {
1533
- const direct = (0, import_types3.serviceId)(host);
1534
- if (graph.hasNode(direct)) return direct;
1535
- let found = null;
1565
+ function resolveServiceId(graph, host, env) {
1566
+ const envTagged = (0, import_types3.serviceId)(host, env);
1567
+ if (graph.hasNode(envTagged)) return envTagged;
1568
+ const envLess = (0, import_types3.serviceId)(host);
1569
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1570
+ let sameEnv = null;
1571
+ let envLessMatch = null;
1572
+ let anyMatch = null;
1536
1573
  graph.forEachNode((id, attrs) => {
1537
- if (found) return;
1574
+ if (sameEnv) return;
1538
1575
  const a = attrs;
1539
1576
  if (a.type !== import_types3.NodeType.ServiceNode) return;
1540
- if (a.name === host) {
1541
- found = id;
1577
+ const matchesByName = a.name === host;
1578
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1579
+ if (!matchesByName && !matchesByAlias) return;
1580
+ const nodeEnv = a.env ?? "unknown";
1581
+ if (nodeEnv === env) {
1582
+ sameEnv = id;
1542
1583
  return;
1543
1584
  }
1544
- if (a.aliases && a.aliases.includes(host)) {
1545
- found = id;
1546
- }
1585
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1586
+ else if (!anyMatch) anyMatch = id;
1547
1587
  });
1548
- return found;
1588
+ return sameEnv ?? envLessMatch ?? anyMatch;
1549
1589
  }
1550
1590
  function frontierIdFor(host) {
1551
1591
  return (0, import_types3.frontierId)(host);
1552
1592
  }
1553
- function ensureServiceNode(graph, serviceName) {
1554
- const id = (0, import_types3.serviceId)(serviceName);
1593
+ function ensureServiceNode(graph, serviceName, env) {
1594
+ const id = (0, import_types3.serviceId)(serviceName, env);
1555
1595
  if (graph.hasNode(id)) return id;
1556
1596
  const node = {
1557
1597
  id,
1558
1598
  type: import_types3.NodeType.ServiceNode,
1559
1599
  name: serviceName,
1560
1600
  language: "unknown",
1561
- discoveredVia: "otel"
1601
+ discoveredVia: "otel",
1602
+ ...env !== "unknown" ? { env } : {}
1562
1603
  };
1563
1604
  graph.addNode(id, node);
1564
1605
  return id;
@@ -1704,7 +1745,7 @@ function buildErrorEventForReceiver(span) {
1704
1745
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1705
1746
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1706
1747
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
1707
- affectedNode: (0, import_types3.serviceId)(span.service)
1748
+ affectedNode: (0, import_types3.serviceId)(span.service, span.env)
1708
1749
  };
1709
1750
  }
1710
1751
  function makeErrorSpanWriter(errorsPath) {
@@ -1718,7 +1759,8 @@ function makeErrorSpanWriter(errorsPath) {
1718
1759
  async function handleSpan(ctx, span) {
1719
1760
  const ts = span.startTimeIso ?? nowIso(ctx);
1720
1761
  const nowMs = ctx.now ? ctx.now() : Date.now();
1721
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1762
+ const env = span.env ?? "unknown";
1763
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1722
1764
  const isError = span.statusCode === 2;
1723
1765
  cacheSpanService(span, nowMs);
1724
1766
  let affectedNode = sourceId;
@@ -1741,7 +1783,7 @@ async function handleSpan(ctx, span) {
1741
1783
  const host = pickAddress(span);
1742
1784
  let resolvedViaAddress = false;
1743
1785
  if (host && host !== span.service) {
1744
- const targetId = resolveServiceId(ctx.graph, host);
1786
+ const targetId = resolveServiceId(ctx.graph, host, env);
1745
1787
  if (targetId && targetId !== sourceId) {
1746
1788
  upsertObservedEdge(
1747
1789
  ctx.graph,
@@ -1768,9 +1810,9 @@ async function handleSpan(ctx, span) {
1768
1810
  }
1769
1811
  }
1770
1812
  if (!resolvedViaAddress && span.parentSpanId) {
1771
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1772
- if (parentService && parentService !== span.service) {
1773
- const parentId = ensureServiceNode(ctx.graph, parentService);
1813
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
1814
+ if (parent && parent.service !== span.service) {
1815
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1774
1816
  upsertObservedEdge(
1775
1817
  ctx.graph,
1776
1818
  import_types3.EdgeType.CALLS,
@@ -1893,6 +1935,28 @@ async function readErrorEvents(errorsPath) {
1893
1935
  throw err;
1894
1936
  }
1895
1937
  }
1938
+ function mergeSnapshot(graph, snapshot) {
1939
+ const exported = snapshot.graph;
1940
+ let nodesAdded = 0;
1941
+ let edgesAdded = 0;
1942
+ for (const node of exported.nodes ?? []) {
1943
+ if (graph.hasNode(node.key)) continue;
1944
+ if (!node.attributes) continue;
1945
+ graph.addNode(node.key, node.attributes);
1946
+ nodesAdded++;
1947
+ }
1948
+ for (const edge of exported.edges ?? []) {
1949
+ const attrs = edge.attributes;
1950
+ if (!attrs) continue;
1951
+ const id = edge.key ?? attrs.id;
1952
+ if (!id) continue;
1953
+ if (graph.hasEdge(id)) continue;
1954
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
1955
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
1956
+ edgesAdded++;
1957
+ }
1958
+ return { nodesAdded, edgesAdded };
1959
+ }
1896
1960
 
1897
1961
  // src/extract/services.ts
1898
1962
  init_cjs_shims();
@@ -2289,6 +2353,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2289
2353
  }
2290
2354
  return [...found];
2291
2355
  }
2356
+ function detectJsFramework(pkg) {
2357
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2358
+ if (deps["next"] !== void 0) return "next";
2359
+ if (deps["remix"] !== void 0) return "remix";
2360
+ for (const k of Object.keys(deps)) {
2361
+ if (k.startsWith("@remix-run/")) return "remix";
2362
+ }
2363
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2364
+ if (deps["nuxt"] !== void 0) return "nuxt";
2365
+ if (deps["astro"] !== void 0) return "astro";
2366
+ return void 0;
2367
+ }
2292
2368
  async function discoverNodeService(scanPath, dir) {
2293
2369
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2294
2370
  if (!await exists(pkgPath)) return null;
@@ -2300,6 +2376,7 @@ async function discoverNodeService(scanPath, dir) {
2300
2376
  return null;
2301
2377
  }
2302
2378
  if (!pkg.name) return null;
2379
+ const framework = detectJsFramework(pkg);
2303
2380
  const node = {
2304
2381
  id: (0, import_types5.serviceId)(pkg.name),
2305
2382
  type: import_types5.NodeType.ServiceNode,
@@ -2308,7 +2385,8 @@ async function discoverNodeService(scanPath, dir) {
2308
2385
  version: pkg.version,
2309
2386
  dependencies: pkg.dependencies ?? {},
2310
2387
  repoPath: import_node_path8.default.relative(scanPath, dir),
2311
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2388
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2389
+ ...framework ? { framework } : {}
2312
2390
  };
2313
2391
  return { pkg, dir, node };
2314
2392
  }
@@ -4091,7 +4169,7 @@ init_cjs_shims();
4091
4169
  var import_node_fs18 = require("fs");
4092
4170
  var import_node_path31 = __toESM(require("path"), 1);
4093
4171
  var import_types19 = require("@neat.is/types");
4094
- var SCHEMA_VERSION = 3;
4172
+ var SCHEMA_VERSION = 4;
4095
4173
  function migrateV1ToV2(payload) {
4096
4174
  const nodes = payload.graph.nodes;
4097
4175
  if (Array.isArray(nodes)) {
@@ -4103,6 +4181,9 @@ function migrateV1ToV2(payload) {
4103
4181
  }
4104
4182
  return { ...payload, schemaVersion: 2 };
4105
4183
  }
4184
+ function migrateV3ToV4(payload) {
4185
+ return { ...payload, schemaVersion: 4 };
4186
+ }
4106
4187
  function migrateV2ToV3(payload) {
4107
4188
  const edges = payload.graph.edges;
4108
4189
  if (Array.isArray(edges)) {
@@ -4151,6 +4232,9 @@ async function loadGraphFromDisk(graph, outPath) {
4151
4232
  if (payload.schemaVersion === 2) {
4152
4233
  payload = migrateV2ToV3(payload);
4153
4234
  }
4235
+ if (payload.schemaVersion === 3) {
4236
+ payload = migrateV3ToV4(payload);
4237
+ }
4154
4238
  if (payload.schemaVersion !== SCHEMA_VERSION) {
4155
4239
  throw new Error(
4156
4240
  `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
@@ -5004,6 +5088,35 @@ function registerRoutes(scope, ctx) {
5004
5088
  }
5005
5089
  }
5006
5090
  );
5091
+ scope.post("/snapshot", async (req2, reply) => {
5092
+ const proj = resolveProject(registry, req2, reply);
5093
+ if (!proj) return;
5094
+ const body = req2.body;
5095
+ if (!body || typeof body !== "object" || !body.snapshot) {
5096
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5097
+ }
5098
+ const snap = body.snapshot;
5099
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5100
+ return reply.code(400).send({
5101
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5102
+ });
5103
+ }
5104
+ try {
5105
+ const result = mergeSnapshot(proj.graph, snap);
5106
+ return {
5107
+ project: proj.name,
5108
+ nodesAdded: result.nodesAdded,
5109
+ edgesAdded: result.edgesAdded,
5110
+ nodeCount: proj.graph.order,
5111
+ edgeCount: proj.graph.size
5112
+ };
5113
+ } catch (err) {
5114
+ return reply.code(400).send({
5115
+ error: "snapshot merge failed",
5116
+ details: err.message
5117
+ });
5118
+ }
5119
+ });
5007
5120
  scope.post("/graph/scan", async (req2, reply) => {
5008
5121
  const proj = resolveProject(registry, req2, reply);
5009
5122
  if (!proj) return;
@@ -5097,7 +5210,15 @@ function registerRoutes(scope, ctx) {
5097
5210
  async function buildApi(opts) {
5098
5211
  const app = (0, import_fastify.default)({ logger: false });
5099
5212
  await app.register(import_cors.default, { origin: true });
5100
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
5213
+ mountBearerAuth(app, {
5214
+ token: opts.authToken,
5215
+ trustProxy: opts.trustProxy,
5216
+ publicRead: opts.publicRead
5217
+ });
5218
+ app.get("/api/config", async () => ({
5219
+ publicRead: opts.publicRead === true,
5220
+ authProxy: opts.trustProxy === true
5221
+ }));
5101
5222
  const startedAt = opts.startedAt ?? Date.now();
5102
5223
  const registry = buildLegacyRegistry(opts);
5103
5224
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5374,7 +5495,8 @@ async function startDaemon(opts = {}) {
5374
5495
  restApp = await buildApi({
5375
5496
  projects: registry,
5376
5497
  authToken: auth.authToken,
5377
- trustProxy: auth.trustProxy
5498
+ trustProxy: auth.trustProxy,
5499
+ publicRead: auth.publicRead
5378
5500
  });
5379
5501
  restAddress = await restApp.listen({ port: restPort, host });
5380
5502
  console.log(`neatd: REST listening on ${restAddress}`);