@netgreener/runtime 0.1.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +28 -0
  3. package/TUTORIAL.md +495 -0
  4. package/dist/aggregator.d.ts +43 -0
  5. package/dist/aggregator.js +270 -0
  6. package/dist/analyze/analyzeGate.d.ts +44 -0
  7. package/dist/analyze/analyzeGate.js +114 -0
  8. package/dist/analyze/apiOverconsumptionDetector.d.ts +13 -0
  9. package/dist/analyze/apiOverconsumptionDetector.js +75 -0
  10. package/dist/analyze/astDetectors.d.ts +38 -0
  11. package/dist/analyze/astDetectors.js +566 -0
  12. package/dist/analyze/buildResourceFindings.d.ts +34 -0
  13. package/dist/analyze/buildResourceFindings.js +86 -0
  14. package/dist/analyze/cli.d.ts +20 -0
  15. package/dist/analyze/cli.js +186 -0
  16. package/dist/analyze/cpuResourceDetectors.d.ts +35 -0
  17. package/dist/analyze/cpuResourceDetectors.js +163 -0
  18. package/dist/analyze/heuristicScan.d.ts +61 -0
  19. package/dist/analyze/heuristicScan.js +91 -0
  20. package/dist/analyze/index.d.ts +15 -0
  21. package/dist/analyze/index.js +15 -0
  22. package/dist/analyze/loadTypescript.d.ts +8 -0
  23. package/dist/analyze/loadTypescript.js +23 -0
  24. package/dist/analyze/memoryIoDetectors.d.ts +15 -0
  25. package/dist/analyze/memoryIoDetectors.js +68 -0
  26. package/dist/analyze/preferAstDetectors.d.ts +18 -0
  27. package/dist/analyze/preferAstDetectors.js +91 -0
  28. package/dist/analyze/projectScan.d.ts +19 -0
  29. package/dist/analyze/projectScan.js +115 -0
  30. package/dist/analyze/resourceAdmission.d.ts +33 -0
  31. package/dist/analyze/resourceAdmission.js +91 -0
  32. package/dist/analyze/resourceFindingCandidate.d.ts +61 -0
  33. package/dist/analyze/resourceFindingCandidate.js +75 -0
  34. package/dist/analyze/retryAmplificationDetector.d.ts +11 -0
  35. package/dist/analyze/retryAmplificationDetector.js +55 -0
  36. package/dist/analyze/schemas/resource-finding.schema.json +352 -0
  37. package/dist/analyze/serviceDiscovery.d.ts +66 -0
  38. package/dist/analyze/serviceDiscovery.js +210 -0
  39. package/dist/analyze/unboundedParallelismDetector.d.ts +11 -0
  40. package/dist/analyze/unboundedParallelismDetector.js +57 -0
  41. package/dist/analyze/validateResourceFinding.d.ts +19 -0
  42. package/dist/analyze/validateResourceFinding.js +46 -0
  43. package/dist/bullmq.d.ts +33 -0
  44. package/dist/bullmq.js +106 -0
  45. package/dist/cli/helpText.d.ts +8 -0
  46. package/dist/cli/helpText.js +99 -0
  47. package/dist/cli/netgreener.d.ts +12 -0
  48. package/dist/cli/netgreener.js +65 -0
  49. package/dist/collectorIpc.d.ts +28 -0
  50. package/dist/collectorIpc.js +189 -0
  51. package/dist/collectorMetadata.d.ts +15 -0
  52. package/dist/collectorMetadata.js +33 -0
  53. package/dist/config.d.ts +41 -0
  54. package/dist/config.js +97 -0
  55. package/dist/contract/index.d.ts +1 -0
  56. package/dist/contract/index.js +1 -0
  57. package/dist/contract/observation-envelope.schema.json +331 -0
  58. package/dist/contract/observation-protobuf-view.schema.json +773 -0
  59. package/dist/contract/observation-v1.d.mts +42 -0
  60. package/dist/contract/observation-v1.mjs +555 -0
  61. package/dist/contract/observationIdentity.d.ts +11 -0
  62. package/dist/contract/observationIdentity.js +48 -0
  63. package/dist/contract/observationProjection.d.ts +3 -0
  64. package/dist/contract/observationProjection.js +29 -0
  65. package/dist/contract/validate.d.ts +20 -0
  66. package/dist/contract/validate.js +227 -0
  67. package/dist/dogfood/mp3WorkerGates.d.ts +54 -0
  68. package/dist/dogfood/mp3WorkerGates.js +86 -0
  69. package/dist/dogfood/retainDryRunArtifact.d.ts +45 -0
  70. package/dist/dogfood/retainDryRunArtifact.js +103 -0
  71. package/dist/dogfood/tenantDogfoodGates.d.ts +76 -0
  72. package/dist/dogfood/tenantDogfoodGates.js +206 -0
  73. package/dist/exporter.d.ts +56 -0
  74. package/dist/exporter.js +304 -0
  75. package/dist/express.d.ts +44 -0
  76. package/dist/express.js +118 -0
  77. package/dist/externalApiMeter.d.ts +72 -0
  78. package/dist/externalApiMeter.js +1167 -0
  79. package/dist/fastify.d.ts +53 -0
  80. package/dist/fastify.js +148 -0
  81. package/dist/index.d.ts +32 -0
  82. package/dist/index.js +31 -0
  83. package/dist/n4/deploymentMatrix.d.ts +22 -0
  84. package/dist/n4/deploymentMatrix.js +57 -0
  85. package/dist/n4/missingnessInventory.d.ts +22 -0
  86. package/dist/n4/missingnessInventory.js +72 -0
  87. package/dist/nest.d.ts +42 -0
  88. package/dist/nest.js +87 -0
  89. package/dist/processResources.d.ts +28 -0
  90. package/dist/processResources.js +31 -0
  91. package/dist/processRuntime.d.ts +35 -0
  92. package/dist/processRuntime.js +96 -0
  93. package/dist/requestContext.d.ts +21 -0
  94. package/dist/requestContext.js +33 -0
  95. package/dist/runtime.d.ts +60 -0
  96. package/dist/runtime.js +289 -0
  97. package/dist/runtimeHealth.d.ts +35 -0
  98. package/dist/runtimeHealth.js +121 -0
  99. package/dist/serverlessHints.d.ts +26 -0
  100. package/dist/serverlessHints.js +52 -0
  101. package/dist/tenantContext.d.ts +78 -0
  102. package/dist/tenantContext.js +226 -0
  103. package/dist/tenantDefaults.d.ts +9 -0
  104. package/dist/tenantDefaults.js +9 -0
  105. package/dist/types.d.ts +131 -0
  106. package/dist/types.js +2 -0
  107. package/dist/uploader.d.ts +23 -0
  108. package/dist/uploader.js +52 -0
  109. package/dist/windowId.d.ts +2 -0
  110. package/dist/windowId.js +7 -0
  111. package/examples/analyze-manifest.mjs +30 -0
  112. package/examples/bullmq-live-smoke.mjs +178 -0
  113. package/examples/bullmq-mp3-worker-gate.mjs +237 -0
  114. package/examples/express-dry-run.mjs +72 -0
  115. package/examples/process-mp3-restart-gate.mjs +135 -0
  116. package/examples/retain-dry-run.mjs +110 -0
  117. package/examples/tenant-dogfood.mjs +269 -0
  118. package/fixtures/analyze-sample/architectureNoise.ts +13 -0
  119. package/fixtures/analyze-sample/cpuHotPaths.ts +27 -0
  120. package/fixtures/analyze-sample/fanOutClient.ts +15 -0
  121. package/fixtures/analyze-sample/inferenceHotPaths.ts +22 -0
  122. package/fixtures/analyze-sample/memoryIoHotPaths.ts +21 -0
  123. package/fixtures/analyze-sample/modelLoadHotPaths.ts +11 -0
  124. package/fixtures/analyze-sample/nestedLookup.ts +15 -0
  125. package/fixtures/analyze-sample/retryClient.ts +10 -0
  126. package/fixtures/analyze-sample/securitySmell.ts +8 -0
  127. package/fixtures/analyze-sample/server.ts +11 -0
  128. package/fixtures/analyze-sample/styleOnly.ts +7 -0
  129. package/fixtures/analyze-sample/worker.ts +4 -0
  130. package/fixtures/dual-view-projection.v1.json +53 -0
  131. package/fixtures/identity-preimages.v1.json +42 -0
  132. package/fixtures/mp2_shadow_digest_golden.v1.json +47 -0
  133. package/fixtures/projection-batch-document.v1.json +170 -0
  134. package/fixtures/projection-batch.v1.json +9 -0
  135. package/fixtures/python_reference_shapes.md +24 -0
  136. package/fixtures/session_metadata_runtime_v0.json +120 -0
  137. package/fixtures/session_metadata_with_tenant.json +85 -0
  138. package/package.json +137 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Gates for Node tenant dogfood (experimental v0).
3
+ *
4
+ * Keeps vendor stubs narrow and live uploads honest:
5
+ * - Stub decision uses **parsed hostname** boundaries (not path/query substrings).
6
+ * - Configured NetGreener API destination always takes precedence (never stubbed).
7
+ * - Failed live uploads must fail the run.
8
+ */
9
+ /** Registrable / DNS suffixes for vendor hosts dogfood may stub. */
10
+ export const VENDOR_FETCH_HOST_SUFFIXES = [
11
+ 'cognitive.microsoft.com',
12
+ 'cognitiveservices.azure.com',
13
+ 'openai.com',
14
+ 'openai.azure.com',
15
+ ];
16
+ /** @deprecated Use VENDOR_FETCH_HOST_SUFFIXES (hostname suffixes, not URL substrings). */
17
+ export const VENDOR_FETCH_HOST_MARKERS = VENDOR_FETCH_HOST_SUFFIXES;
18
+ export function resolveFetchUrl(input) {
19
+ if (typeof input === 'string')
20
+ return input;
21
+ if (input instanceof URL)
22
+ return input.toString();
23
+ if (input && typeof input === 'object' && 'url' in input) {
24
+ return String(input.url || '');
25
+ }
26
+ return String(input);
27
+ }
28
+ /**
29
+ * Extract hostname from a fetch destination. Returns null when the input is
30
+ * not an absolute http(s) URL (relative paths are not stubbed).
31
+ */
32
+ export function parseFetchHostname(input) {
33
+ const raw = typeof input === 'string' || input instanceof URL ? String(input) : resolveFetchUrl(input);
34
+ const trimmed = raw.trim();
35
+ if (!trimmed)
36
+ return null;
37
+ try {
38
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
39
+ return new URL(trimmed).hostname.toLowerCase();
40
+ }
41
+ // Relative / opaque — do not treat path fragments as hosts.
42
+ return null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** True when hostname is exactly a vendor suffix or a subdomain of one. */
49
+ export function hostnameMatchesVendor(hostname) {
50
+ const host = hostname.toLowerCase().replace(/\.$/, '');
51
+ if (!host)
52
+ return false;
53
+ return VENDOR_FETCH_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`));
54
+ }
55
+ export function parseApiHostname(apiBaseUrl) {
56
+ if (!apiBaseUrl || !String(apiBaseUrl).trim())
57
+ return null;
58
+ try {
59
+ const raw = String(apiBaseUrl).trim();
60
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw) ? raw : `http://${raw}`;
61
+ return new URL(withScheme).hostname.toLowerCase() || null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /** Explicit API destination never stubs, even on a lookalike/vendor-named host. */
68
+ export function isConfiguredApiDestination(hostname, apiBaseUrl) {
69
+ const apiHost = parseApiHostname(apiBaseUrl);
70
+ if (!apiHost)
71
+ return false;
72
+ return hostname.toLowerCase() === apiHost;
73
+ }
74
+ /**
75
+ * True only for explicit vendor hostnames that dogfood may stub.
76
+ * Path/query substrings and lookalike hosts do not match.
77
+ * Configured API host always wins (real HTTP).
78
+ */
79
+ export function shouldStubVendorFetch(input, opts) {
80
+ const hostname = parseFetchHostname(input);
81
+ if (!hostname)
82
+ return false;
83
+ if (isConfiguredApiDestination(hostname, opts?.apiBaseUrl))
84
+ return false;
85
+ return hostnameMatchesVendor(hostname);
86
+ }
87
+ export function stubVendorResponse(input) {
88
+ const hostname = parseFetchHostname(input) || '';
89
+ if (hostnameMatchesVendor(hostname) &&
90
+ (hostname === 'cognitive.microsoft.com' ||
91
+ hostname.endsWith('.cognitive.microsoft.com') ||
92
+ hostname === 'cognitiveservices.azure.com' ||
93
+ hostname.endsWith('.cognitiveservices.azure.com'))) {
94
+ return new Response(JSON.stringify({ status: 'succeeded' }), {
95
+ status: 200,
96
+ headers: { 'content-type': 'application/json' },
97
+ });
98
+ }
99
+ if (hostnameMatchesVendor(hostname) &&
100
+ (hostname === 'openai.com' ||
101
+ hostname.endsWith('.openai.com') ||
102
+ hostname === 'openai.azure.com' ||
103
+ hostname.endsWith('.openai.azure.com'))) {
104
+ return new Response(JSON.stringify({
105
+ model: 'gpt-4o-mini',
106
+ usage: { prompt_tokens: 120, completion_tokens: 40 },
107
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
108
+ }
109
+ return new Response(JSON.stringify({ ok: true }), {
110
+ status: 200,
111
+ headers: { 'content-type': 'application/json' },
112
+ });
113
+ }
114
+ /**
115
+ * Wrap fetch so only known vendor **hostnames** are stubbed.
116
+ * The configured API destination always uses real HTTP.
117
+ */
118
+ export function createVendorAwareFetch(realFetch, opts) {
119
+ return (async (input, init) => {
120
+ if (shouldStubVendorFetch(input, opts)) {
121
+ return stubVendorResponse(input);
122
+ }
123
+ return realFetch(input, init);
124
+ });
125
+ }
126
+ export function assertUploadForMode(label, result, opts) {
127
+ const live = opts.live;
128
+ if (!result) {
129
+ return { ok: false, reason: `${label}: missing flush/upload result` };
130
+ }
131
+ if (!live) {
132
+ if (!result.ok) {
133
+ return {
134
+ ok: false,
135
+ reason: `${label}: ${'error' in result ? result.error : 'upload failed'}`,
136
+ };
137
+ }
138
+ return { ok: true };
139
+ }
140
+ if (!result.ok) {
141
+ return {
142
+ ok: false,
143
+ reason: `${label}: live upload failed: ${result.error}${result.status != null ? ` (HTTP ${result.status})` : ''}`,
144
+ };
145
+ }
146
+ if (result.dryRun) {
147
+ return { ok: false, reason: `${label}: expected live upload, got dry-run result` };
148
+ }
149
+ const status = Number(result.status);
150
+ if (!Number.isFinite(status) || status < 200 || status >= 300) {
151
+ return { ok: false, reason: `${label}: unexpected live HTTP status ${result.status}` };
152
+ }
153
+ return { ok: true };
154
+ }
155
+ /**
156
+ * In live mode, never treat a local onFlush payload as proof of persistence.
157
+ * In dry-run, allow the dry-run body or the captured onFlush payload.
158
+ */
159
+ export function sessionMetadataForDogfood(opts) {
160
+ const label = opts.label || 'flush';
161
+ const uploadGate = assertUploadForMode(label, opts.flushResult, { live: opts.live });
162
+ if (!uploadGate.ok) {
163
+ return { sessionMetadata: null, uploadGate };
164
+ }
165
+ if (opts.live) {
166
+ const fromPayload = opts.flushPayload?.session_metadata;
167
+ if (fromPayload && typeof fromPayload === 'object') {
168
+ return {
169
+ sessionMetadata: fromPayload,
170
+ uploadGate,
171
+ };
172
+ }
173
+ return {
174
+ sessionMetadata: null,
175
+ uploadGate: {
176
+ ok: false,
177
+ reason: `${label}: live upload ok but session_metadata missing on payload`,
178
+ },
179
+ };
180
+ }
181
+ const body = opts.flushResult && opts.flushResult.ok ? opts.flushResult.body : null;
182
+ if (body && typeof body === 'object' && body !== null && 'session_metadata' in body) {
183
+ const meta = body.session_metadata;
184
+ if (meta && typeof meta === 'object') {
185
+ return { sessionMetadata: meta, uploadGate };
186
+ }
187
+ }
188
+ const captured = opts.flushPayload?.session_metadata;
189
+ if (captured && typeof captured === 'object') {
190
+ return { sessionMetadata: captured, uploadGate };
191
+ }
192
+ return {
193
+ sessionMetadata: null,
194
+ uploadGate: { ok: false, reason: `${label}: dry-run session_metadata missing` },
195
+ };
196
+ }
197
+ export function extractPersistedRunId(uploadBody) {
198
+ if (!uploadBody || typeof uploadBody !== 'object')
199
+ return null;
200
+ const row = uploadBody;
201
+ if (typeof row.run_id === 'number' || typeof row.run_id === 'string')
202
+ return row.run_id;
203
+ if (typeof row.id === 'number' || typeof row.id === 'string')
204
+ return row.id;
205
+ return null;
206
+ }
@@ -0,0 +1,56 @@
1
+ import type { RuntimeConfig } from './config.js';
2
+ import { type RunSessionCreatePayload, type UploadResult } from './uploader.js';
3
+ export type ExportMode = 'direct' | 'collector' | 'thin';
4
+ export type ExportResult = UploadResult & {
5
+ mode: ExportMode;
6
+ durabilityGrade: 'best_effort_in_process' | 'durable_sibling_spool' | 'best_effort_bounded';
7
+ collectorEventId?: string;
8
+ };
9
+ /** NetGreener Observation v1 batch document (semantic envelope object). */
10
+ export type ObservationBatchDocument = Record<string, unknown>;
11
+ export type ObservationExporter = {
12
+ readonly mode: ExportMode;
13
+ exportRunSessionWindow(config: RuntimeConfig, payload: RunSessionCreatePayload, fetchImpl?: typeof fetch): Promise<ExportResult>;
14
+ exportObservationBatch(config: RuntimeConfig, batch: ObservationBatchDocument, options?: {
15
+ projectId?: number;
16
+ }): Promise<ExportResult>;
17
+ };
18
+ /** Opt-in digest logging; mirrors Python ``shadow_compare_enabled``. */
19
+ export declare function shadowCompareEnabled(env?: NodeJS.ProcessEnv): boolean;
20
+ /** Stable JSON matching Python ``json.dumps(..., sort_keys=True, separators=(",", ":"))``. */
21
+ export declare function stableStringify(value: unknown): string;
22
+ export declare function canonicalPayloadDigest(payload: Record<string, unknown>): string;
23
+ /** RunSession body shape used by Python ``run_session_payload_from_ipc`` digests. */
24
+ export declare function runSessionShadowPayload(payload: RunSessionCreatePayload): Record<string, unknown>;
25
+ /** Existing Node v0 path: adapter posts RunSession directly. */
26
+ export declare class DirectRunSessionExporter implements ObservationExporter {
27
+ readonly mode: ExportMode;
28
+ exportRunSessionWindow(config: RuntimeConfig, payload: RunSessionCreatePayload, fetchImpl?: typeof fetch): Promise<ExportResult>;
29
+ exportObservationBatch(_config: RuntimeConfig, _batch: ObservationBatchDocument): Promise<ExportResult>;
30
+ }
31
+ /**
32
+ * Thin/emergency alias of direct upload with an explicit durability grade.
33
+ * Same POST path today; grade is declared for evidence honesty.
34
+ */
35
+ export declare class ThinRunSessionExporter implements ObservationExporter {
36
+ readonly mode: ExportMode;
37
+ exportRunSessionWindow(config: RuntimeConfig, payload: RunSessionCreatePayload, fetchImpl?: typeof fetch): Promise<ExportResult>;
38
+ exportObservationBatch(_config: RuntimeConfig, _batch: ObservationBatchDocument): Promise<ExportResult>;
39
+ }
40
+ /**
41
+ * Collector-backed export (MP2). Not the default.
42
+ *
43
+ * ``run_session_window`` and ``observation_batch`` IPC frames (Python
44
+ * collector_protocol compatible). Durable spool ACK only — Observation upload
45
+ * bridge is a later slice. Never reports silent success when the collector is
46
+ * absent or rejects.
47
+ */
48
+ export declare class CollectorRunSessionExporter implements ObservationExporter {
49
+ readonly mode: ExportMode;
50
+ exportRunSessionWindow(config: RuntimeConfig, payload: RunSessionCreatePayload, _fetchImpl?: typeof fetch): Promise<ExportResult>;
51
+ exportObservationBatch(config: RuntimeConfig, batch: ObservationBatchDocument, options?: {
52
+ projectId?: number;
53
+ }): Promise<ExportResult>;
54
+ }
55
+ export declare function resolveObservationExporter(env?: NodeJS.ProcessEnv): ObservationExporter;
56
+ export declare function exportModeFromEnv(env?: NodeJS.ProcessEnv): ExportMode;
@@ -0,0 +1,304 @@
1
+ /**
2
+ * MP2 adapter exporter surface.
3
+ *
4
+ * Default mode is ``direct`` (existing uploadRunSession path). Collector mode is
5
+ * opt-in IPC to a NetGreener collector (durable spool ACK). Do not change the live
6
+ * default without repeated product-owner authorization.
7
+ *
8
+ * ``NETGREENER_MP2_SHADOW_COMPARE=1`` logs canonical payload digests only
9
+ * (no dual-POST, no default flip). Matches Python bridge digest semantics.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { sendCollectorEvent, newCollectorEventId, CollectorIpcError, } from './collectorIpc.js';
13
+ import { uploadRunSession, } from './uploader.js';
14
+ import { flushModeRequestsThin } from './serverlessHints.js';
15
+ function readExportMode(env = process.env) {
16
+ // Explicit EXPORT_MODE always wins (including explicit ``direct``).
17
+ const exportRaw = (env.NETGREENER_EXPORT_MODE || '').trim().toLowerCase();
18
+ if (exportRaw === 'collector' || exportRaw === 'thin' || exportRaw === 'direct') {
19
+ return exportRaw;
20
+ }
21
+ // Alias: FLUSH_MODE=thin when EXPORT_MODE unset — does not auto-detect platforms.
22
+ if (flushModeRequestsThin(env))
23
+ return 'thin';
24
+ return 'direct';
25
+ }
26
+ function emitTimeoutMs(env = process.env) {
27
+ const raw = Number((env.NETGREENER_EMIT_TIMEOUT_MS || '100').trim());
28
+ return Number.isFinite(raw) && raw > 0 ? raw : 100;
29
+ }
30
+ /** Opt-in digest logging; mirrors Python ``shadow_compare_enabled``. */
31
+ export function shadowCompareEnabled(env = process.env) {
32
+ const raw = String(env.NETGREENER_MP2_SHADOW_COMPARE || '')
33
+ .trim()
34
+ .toLowerCase();
35
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
36
+ }
37
+ /** Stable JSON matching Python ``json.dumps(..., sort_keys=True, separators=(",", ":"))``. */
38
+ export function stableStringify(value) {
39
+ if (value === null || typeof value !== 'object') {
40
+ return JSON.stringify(value);
41
+ }
42
+ if (Array.isArray(value)) {
43
+ return `[${value.map((item) => stableStringify(item)).join(',')}]`;
44
+ }
45
+ const obj = value;
46
+ const keys = Object.keys(obj).sort();
47
+ return `{${keys
48
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
49
+ .join(',')}}`;
50
+ }
51
+ export function canonicalPayloadDigest(payload) {
52
+ return createHash('sha256').update(stableStringify(payload), 'utf8').digest('hex');
53
+ }
54
+ /** RunSession body shape used by Python ``run_session_payload_from_ipc`` digests. */
55
+ export function runSessionShadowPayload(payload) {
56
+ return {
57
+ project_id: payload.project_id,
58
+ runtime_window_id: payload.runtime_window_id,
59
+ start_time: payload.start_time,
60
+ end_time: payload.end_time,
61
+ server_name: payload.server_name ?? null,
62
+ energy_kwh: payload.energy_kwh ?? null,
63
+ session_metadata: payload.session_metadata ?? null,
64
+ };
65
+ }
66
+ function logShadowCompare(kind, idField, idValue, digest) {
67
+ console.info(`mp2_shadow_compare kind=${kind} ${idField}=${idValue} payload_sha256=${digest} emitter=netgreener_node`);
68
+ }
69
+ function maybeShadowRunSession(payload) {
70
+ if (!shadowCompareEnabled())
71
+ return;
72
+ const body = runSessionShadowPayload(payload);
73
+ logShadowCompare('run_session_window', 'runtime_window_id', String(body.runtime_window_id ?? ''), canonicalPayloadDigest(body));
74
+ }
75
+ function maybeShadowObservationBatch(batch) {
76
+ if (!shadowCompareEnabled())
77
+ return;
78
+ const batchId = typeof batch.batch_id === 'string' ? batch.batch_id : '';
79
+ logShadowCompare('observation_batch', 'batch_id', batchId, canonicalPayloadDigest(batch));
80
+ }
81
+ function resolveProjectId(config, batch, options) {
82
+ if (options?.projectId != null && Number.isFinite(options.projectId)) {
83
+ return Number(options.projectId);
84
+ }
85
+ const routing = batch.routing;
86
+ if (routing && typeof routing === 'object' && !Array.isArray(routing)) {
87
+ const routed = routing.project_id;
88
+ const parsed = Number(routed);
89
+ if (Number.isFinite(parsed) && parsed > 0)
90
+ return parsed;
91
+ }
92
+ if (Number.isFinite(config.projectId) && config.projectId > 0)
93
+ return config.projectId;
94
+ return null;
95
+ }
96
+ function observationBatchNotWired(mode, grade) {
97
+ return {
98
+ ok: false,
99
+ error: 'export_observation_batch is stub-ready for collector spool only; direct/thin Observation upload is not wired (no cutover)',
100
+ mode,
101
+ durabilityGrade: grade,
102
+ };
103
+ }
104
+ /** Existing Node v0 path: adapter posts RunSession directly. */
105
+ export class DirectRunSessionExporter {
106
+ mode = 'direct';
107
+ async exportRunSessionWindow(config, payload, fetchImpl) {
108
+ const result = await uploadRunSession(config, payload, fetchImpl);
109
+ return { ...result, mode: 'direct', durabilityGrade: 'best_effort_in_process' };
110
+ }
111
+ async exportObservationBatch(_config, _batch) {
112
+ return observationBatchNotWired('direct', 'best_effort_in_process');
113
+ }
114
+ }
115
+ /**
116
+ * Thin/emergency alias of direct upload with an explicit durability grade.
117
+ * Same POST path today; grade is declared for evidence honesty.
118
+ */
119
+ export class ThinRunSessionExporter {
120
+ mode = 'thin';
121
+ async exportRunSessionWindow(config, payload, fetchImpl) {
122
+ const result = await uploadRunSession(config, payload, fetchImpl);
123
+ return { ...result, mode: 'thin', durabilityGrade: 'best_effort_bounded' };
124
+ }
125
+ async exportObservationBatch(_config, _batch) {
126
+ return observationBatchNotWired('thin', 'best_effort_bounded');
127
+ }
128
+ }
129
+ /**
130
+ * Collector-backed export (MP2). Not the default.
131
+ *
132
+ * ``run_session_window`` and ``observation_batch`` IPC frames (Python
133
+ * collector_protocol compatible). Durable spool ACK only — Observation upload
134
+ * bridge is a later slice. Never reports silent success when the collector is
135
+ * absent or rejects.
136
+ */
137
+ export class CollectorRunSessionExporter {
138
+ mode = 'collector';
139
+ async exportRunSessionWindow(config, payload, _fetchImpl) {
140
+ const endpoint = (process.env.NETGREENER_COLLECTOR_ENDPOINT || '').trim();
141
+ if (!endpoint) {
142
+ return {
143
+ ok: false,
144
+ error: 'collector export mode requires NETGREENER_COLLECTOR_ENDPOINT',
145
+ mode: 'collector',
146
+ durabilityGrade: 'durable_sibling_spool',
147
+ };
148
+ }
149
+ if (config.dryRun) {
150
+ maybeShadowRunSession(payload);
151
+ return {
152
+ ok: true,
153
+ status: 0,
154
+ body: payload,
155
+ dryRun: true,
156
+ mode: 'collector',
157
+ durabilityGrade: 'durable_sibling_spool',
158
+ };
159
+ }
160
+ const eventId = newCollectorEventId();
161
+ try {
162
+ const ack = await sendCollectorEvent({
163
+ endpoint,
164
+ timeoutMs: emitTimeoutMs(),
165
+ payload: {
166
+ kind: 'run_session_window',
167
+ event_id: eventId,
168
+ project_id: payload.project_id,
169
+ runtime_window_id: payload.runtime_window_id,
170
+ start_time: payload.start_time,
171
+ end_time: payload.end_time,
172
+ server_name: payload.server_name ?? null,
173
+ energy_kwh: payload.energy_kwh ?? null,
174
+ session_metadata: payload.session_metadata,
175
+ wall_time_utc: new Date().toISOString(),
176
+ emitter: 'netgreener_node',
177
+ export_mode: 'collector',
178
+ },
179
+ });
180
+ if (!ack.ok) {
181
+ return {
182
+ ok: false,
183
+ error: ack.error || 'collector rejected run_session_window',
184
+ mode: 'collector',
185
+ durabilityGrade: 'durable_sibling_spool',
186
+ collectorEventId: ack.event_id || eventId,
187
+ };
188
+ }
189
+ maybeShadowRunSession(payload);
190
+ return {
191
+ ok: true,
192
+ status: 0,
193
+ body: { event_id: ack.event_id || eventId },
194
+ mode: 'collector',
195
+ durabilityGrade: 'durable_sibling_spool',
196
+ collectorEventId: ack.event_id || eventId,
197
+ };
198
+ }
199
+ catch (err) {
200
+ const message = err instanceof CollectorIpcError
201
+ ? err.message
202
+ : err instanceof Error
203
+ ? err.message
204
+ : String(err);
205
+ return {
206
+ ok: false,
207
+ error: `collector IPC failed: ${message}`,
208
+ mode: 'collector',
209
+ durabilityGrade: 'durable_sibling_spool',
210
+ collectorEventId: eventId,
211
+ };
212
+ }
213
+ }
214
+ async exportObservationBatch(config, batch, options) {
215
+ const endpoint = (process.env.NETGREENER_COLLECTOR_ENDPOINT || '').trim();
216
+ if (!endpoint) {
217
+ return {
218
+ ok: false,
219
+ error: 'collector export mode requires NETGREENER_COLLECTOR_ENDPOINT',
220
+ mode: 'collector',
221
+ durabilityGrade: 'durable_sibling_spool',
222
+ };
223
+ }
224
+ const projectId = resolveProjectId(config, batch, options);
225
+ if (projectId == null) {
226
+ return {
227
+ ok: false,
228
+ error: 'observation_batch requires project_id (options, batch.routing, or config)',
229
+ mode: 'collector',
230
+ durabilityGrade: 'durable_sibling_spool',
231
+ };
232
+ }
233
+ if (config.dryRun) {
234
+ maybeShadowObservationBatch(batch);
235
+ return {
236
+ ok: true,
237
+ status: 0,
238
+ body: batch,
239
+ dryRun: true,
240
+ mode: 'collector',
241
+ durabilityGrade: 'durable_sibling_spool',
242
+ };
243
+ }
244
+ const eventId = newCollectorEventId();
245
+ try {
246
+ const ack = await sendCollectorEvent({
247
+ endpoint,
248
+ timeoutMs: emitTimeoutMs(),
249
+ payload: {
250
+ kind: 'observation_batch',
251
+ event_id: eventId,
252
+ project_id: projectId,
253
+ batch,
254
+ wall_time_utc: new Date().toISOString(),
255
+ emitter: 'netgreener_node',
256
+ export_mode: 'collector',
257
+ },
258
+ });
259
+ if (!ack.ok) {
260
+ return {
261
+ ok: false,
262
+ error: ack.error || 'collector rejected observation_batch',
263
+ mode: 'collector',
264
+ durabilityGrade: 'durable_sibling_spool',
265
+ collectorEventId: ack.event_id || eventId,
266
+ };
267
+ }
268
+ maybeShadowObservationBatch(batch);
269
+ return {
270
+ ok: true,
271
+ status: 0,
272
+ body: { event_id: ack.event_id || eventId },
273
+ mode: 'collector',
274
+ durabilityGrade: 'durable_sibling_spool',
275
+ collectorEventId: ack.event_id || eventId,
276
+ };
277
+ }
278
+ catch (err) {
279
+ const message = err instanceof CollectorIpcError
280
+ ? err.message
281
+ : err instanceof Error
282
+ ? err.message
283
+ : String(err);
284
+ return {
285
+ ok: false,
286
+ error: `collector IPC failed: ${message}`,
287
+ mode: 'collector',
288
+ durabilityGrade: 'durable_sibling_spool',
289
+ collectorEventId: eventId,
290
+ };
291
+ }
292
+ }
293
+ }
294
+ export function resolveObservationExporter(env = process.env) {
295
+ const mode = readExportMode(env);
296
+ if (mode === 'collector')
297
+ return new CollectorRunSessionExporter();
298
+ if (mode === 'thin')
299
+ return new ThinRunSessionExporter();
300
+ return new DirectRunSessionExporter();
301
+ }
302
+ export function exportModeFromEnv(env = process.env) {
303
+ return readExportMode(env);
304
+ }
@@ -0,0 +1,44 @@
1
+ import { type NetGreenerRuntime, type NetGreenerRuntimeOptions } from './runtime.js';
2
+ /** Minimal Express-compatible types (avoid hard dependency for unit tests). */
3
+ export type ExpressRequest = {
4
+ method?: string;
5
+ path?: string;
6
+ url?: string;
7
+ route?: {
8
+ path?: string;
9
+ };
10
+ baseUrl?: string;
11
+ headers?: Record<string, unknown>;
12
+ };
13
+ export type ExpressResponse = {
14
+ statusCode?: number;
15
+ on: (event: string, listener: (...args: unknown[]) => void) => unknown;
16
+ };
17
+ export type ExpressNext = (err?: unknown) => void;
18
+ /**
19
+ * Bounded template for service_unit attribution.
20
+ * Never fall back to raw customer paths, baseUrl mounts, or query strings.
21
+ */
22
+ export declare const UNKNOWN_ROUTE_TEMPLATE = "<unmatched>";
23
+ /**
24
+ * Return only a framework-owned route template.
25
+ *
26
+ * Express ``req.path``, ``req.url``, and ``req.baseUrl`` may contain customer
27
+ * identifiers or query values — they are never safe metering fallbacks.
28
+ * Prefer resolving this at finish/close (after routing) rather than at entry.
29
+ */
30
+ export declare function expressRouteTemplate(req: ExpressRequest): string;
31
+ /**
32
+ * Express middleware: record per-route latency/status into NetGreenerRuntime.
33
+ *
34
+ * Enable with NETGREENER_SERVICE_RUNTIME=1 and credentials (see README).
35
+ * Also installs outbound ``fetch`` metering (N2) when runtime is enabled.
36
+ *
37
+ * Route identity is resolved when the response finishes (or on sync handler
38
+ * throw), so Express can attach ``req.route`` after middleware entry.
39
+ *
40
+ * Manual ``setTenantContext`` after auth mutates the request tenant store in
41
+ * place; finish/close read that store rather than freezing the entry-time value.
42
+ */
43
+ export declare function netgreenerExpressMiddleware(opts?: NetGreenerRuntimeOptions): (req: ExpressRequest, res: ExpressResponse, next: ExpressNext) => void;
44
+ export declare function getExpressRuntime(): NetGreenerRuntime;