@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.
- package/CHANGELOG.md +16 -0
- package/README.md +28 -0
- package/TUTORIAL.md +495 -0
- package/dist/aggregator.d.ts +43 -0
- package/dist/aggregator.js +270 -0
- package/dist/analyze/analyzeGate.d.ts +44 -0
- package/dist/analyze/analyzeGate.js +114 -0
- package/dist/analyze/apiOverconsumptionDetector.d.ts +13 -0
- package/dist/analyze/apiOverconsumptionDetector.js +75 -0
- package/dist/analyze/astDetectors.d.ts +38 -0
- package/dist/analyze/astDetectors.js +566 -0
- package/dist/analyze/buildResourceFindings.d.ts +34 -0
- package/dist/analyze/buildResourceFindings.js +86 -0
- package/dist/analyze/cli.d.ts +20 -0
- package/dist/analyze/cli.js +186 -0
- package/dist/analyze/cpuResourceDetectors.d.ts +35 -0
- package/dist/analyze/cpuResourceDetectors.js +163 -0
- package/dist/analyze/heuristicScan.d.ts +61 -0
- package/dist/analyze/heuristicScan.js +91 -0
- package/dist/analyze/index.d.ts +15 -0
- package/dist/analyze/index.js +15 -0
- package/dist/analyze/loadTypescript.d.ts +8 -0
- package/dist/analyze/loadTypescript.js +23 -0
- package/dist/analyze/memoryIoDetectors.d.ts +15 -0
- package/dist/analyze/memoryIoDetectors.js +68 -0
- package/dist/analyze/preferAstDetectors.d.ts +18 -0
- package/dist/analyze/preferAstDetectors.js +91 -0
- package/dist/analyze/projectScan.d.ts +19 -0
- package/dist/analyze/projectScan.js +115 -0
- package/dist/analyze/resourceAdmission.d.ts +33 -0
- package/dist/analyze/resourceAdmission.js +91 -0
- package/dist/analyze/resourceFindingCandidate.d.ts +61 -0
- package/dist/analyze/resourceFindingCandidate.js +75 -0
- package/dist/analyze/retryAmplificationDetector.d.ts +11 -0
- package/dist/analyze/retryAmplificationDetector.js +55 -0
- package/dist/analyze/schemas/resource-finding.schema.json +352 -0
- package/dist/analyze/serviceDiscovery.d.ts +66 -0
- package/dist/analyze/serviceDiscovery.js +210 -0
- package/dist/analyze/unboundedParallelismDetector.d.ts +11 -0
- package/dist/analyze/unboundedParallelismDetector.js +57 -0
- package/dist/analyze/validateResourceFinding.d.ts +19 -0
- package/dist/analyze/validateResourceFinding.js +46 -0
- package/dist/bullmq.d.ts +33 -0
- package/dist/bullmq.js +106 -0
- package/dist/cli/helpText.d.ts +8 -0
- package/dist/cli/helpText.js +99 -0
- package/dist/cli/netgreener.d.ts +12 -0
- package/dist/cli/netgreener.js +65 -0
- package/dist/collectorIpc.d.ts +28 -0
- package/dist/collectorIpc.js +189 -0
- package/dist/collectorMetadata.d.ts +15 -0
- package/dist/collectorMetadata.js +33 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +97 -0
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +1 -0
- package/dist/contract/observation-envelope.schema.json +331 -0
- package/dist/contract/observation-protobuf-view.schema.json +773 -0
- package/dist/contract/observation-v1.d.mts +42 -0
- package/dist/contract/observation-v1.mjs +555 -0
- package/dist/contract/observationIdentity.d.ts +11 -0
- package/dist/contract/observationIdentity.js +48 -0
- package/dist/contract/observationProjection.d.ts +3 -0
- package/dist/contract/observationProjection.js +29 -0
- package/dist/contract/validate.d.ts +20 -0
- package/dist/contract/validate.js +227 -0
- package/dist/dogfood/mp3WorkerGates.d.ts +54 -0
- package/dist/dogfood/mp3WorkerGates.js +86 -0
- package/dist/dogfood/retainDryRunArtifact.d.ts +45 -0
- package/dist/dogfood/retainDryRunArtifact.js +103 -0
- package/dist/dogfood/tenantDogfoodGates.d.ts +76 -0
- package/dist/dogfood/tenantDogfoodGates.js +206 -0
- package/dist/exporter.d.ts +56 -0
- package/dist/exporter.js +304 -0
- package/dist/express.d.ts +44 -0
- package/dist/express.js +118 -0
- package/dist/externalApiMeter.d.ts +72 -0
- package/dist/externalApiMeter.js +1167 -0
- package/dist/fastify.d.ts +53 -0
- package/dist/fastify.js +148 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +31 -0
- package/dist/n4/deploymentMatrix.d.ts +22 -0
- package/dist/n4/deploymentMatrix.js +57 -0
- package/dist/n4/missingnessInventory.d.ts +22 -0
- package/dist/n4/missingnessInventory.js +72 -0
- package/dist/nest.d.ts +42 -0
- package/dist/nest.js +87 -0
- package/dist/processResources.d.ts +28 -0
- package/dist/processResources.js +31 -0
- package/dist/processRuntime.d.ts +35 -0
- package/dist/processRuntime.js +96 -0
- package/dist/requestContext.d.ts +21 -0
- package/dist/requestContext.js +33 -0
- package/dist/runtime.d.ts +60 -0
- package/dist/runtime.js +289 -0
- package/dist/runtimeHealth.d.ts +35 -0
- package/dist/runtimeHealth.js +121 -0
- package/dist/serverlessHints.d.ts +26 -0
- package/dist/serverlessHints.js +52 -0
- package/dist/tenantContext.d.ts +78 -0
- package/dist/tenantContext.js +226 -0
- package/dist/tenantDefaults.d.ts +9 -0
- package/dist/tenantDefaults.js +9 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.js +2 -0
- package/dist/uploader.d.ts +23 -0
- package/dist/uploader.js +52 -0
- package/dist/windowId.d.ts +2 -0
- package/dist/windowId.js +7 -0
- package/examples/analyze-manifest.mjs +30 -0
- package/examples/bullmq-live-smoke.mjs +178 -0
- package/examples/bullmq-mp3-worker-gate.mjs +237 -0
- package/examples/express-dry-run.mjs +72 -0
- package/examples/process-mp3-restart-gate.mjs +135 -0
- package/examples/retain-dry-run.mjs +110 -0
- package/examples/tenant-dogfood.mjs +269 -0
- package/fixtures/analyze-sample/architectureNoise.ts +13 -0
- package/fixtures/analyze-sample/cpuHotPaths.ts +27 -0
- package/fixtures/analyze-sample/fanOutClient.ts +15 -0
- package/fixtures/analyze-sample/inferenceHotPaths.ts +22 -0
- package/fixtures/analyze-sample/memoryIoHotPaths.ts +21 -0
- package/fixtures/analyze-sample/modelLoadHotPaths.ts +11 -0
- package/fixtures/analyze-sample/nestedLookup.ts +15 -0
- package/fixtures/analyze-sample/retryClient.ts +10 -0
- package/fixtures/analyze-sample/securitySmell.ts +8 -0
- package/fixtures/analyze-sample/server.ts +11 -0
- package/fixtures/analyze-sample/styleOnly.ts +7 -0
- package/fixtures/analyze-sample/worker.ts +4 -0
- package/fixtures/dual-view-projection.v1.json +53 -0
- package/fixtures/identity-preimages.v1.json +42 -0
- package/fixtures/mp2_shadow_digest_golden.v1.json +47 -0
- package/fixtures/projection-batch-document.v1.json +170 -0
- package/fixtures/projection-batch.v1.json +9 -0
- package/fixtures/python_reference_shapes.md +24 -0
- package/fixtures/session_metadata_runtime_v0.json +120 -0
- package/fixtures/session_metadata_with_tenant.json +85 -0
- package/package.json +137 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime / export health (N6 / CAP-R5 honesty).
|
|
3
|
+
*
|
|
4
|
+
* Additive ``runtime_health_v0`` twin of Python ``netgreener.runtime_health``.
|
|
5
|
+
* Does not change upload defaults or invent collector spool metrics.
|
|
6
|
+
*/
|
|
7
|
+
const state = {
|
|
8
|
+
hooksActive: false,
|
|
9
|
+
processKind: 'process',
|
|
10
|
+
bootstrap: 'node_runtime_v0',
|
|
11
|
+
codes: [],
|
|
12
|
+
exportMode: 'direct',
|
|
13
|
+
exportAttempts: 0,
|
|
14
|
+
exportOk: 0,
|
|
15
|
+
exportFail: 0,
|
|
16
|
+
exportSkippedInFlight: 0,
|
|
17
|
+
lastError: null,
|
|
18
|
+
};
|
|
19
|
+
export function _resetRuntimeHealthForTests() {
|
|
20
|
+
state.hooksActive = false;
|
|
21
|
+
state.processKind = 'process';
|
|
22
|
+
state.bootstrap = 'node_runtime_v0';
|
|
23
|
+
state.codes = [];
|
|
24
|
+
state.exportMode = 'direct';
|
|
25
|
+
state.exportAttempts = 0;
|
|
26
|
+
state.exportOk = 0;
|
|
27
|
+
state.exportFail = 0;
|
|
28
|
+
state.exportSkippedInFlight = 0;
|
|
29
|
+
state.lastError = null;
|
|
30
|
+
}
|
|
31
|
+
export function markHooksActive(opts) {
|
|
32
|
+
state.hooksActive = true;
|
|
33
|
+
if (opts?.processKind)
|
|
34
|
+
state.processKind = opts.processKind;
|
|
35
|
+
if (opts?.exportMode)
|
|
36
|
+
state.exportMode = opts.exportMode;
|
|
37
|
+
}
|
|
38
|
+
export function noteExportMode(mode) {
|
|
39
|
+
state.exportMode = mode;
|
|
40
|
+
}
|
|
41
|
+
export function noteFlushSkippedInFlight() {
|
|
42
|
+
state.exportSkippedInFlight += 1;
|
|
43
|
+
}
|
|
44
|
+
export function noteExportAttempt(mode) {
|
|
45
|
+
state.exportMode = mode;
|
|
46
|
+
state.exportAttempts += 1;
|
|
47
|
+
}
|
|
48
|
+
export function noteExportResult(ok, error) {
|
|
49
|
+
if (ok) {
|
|
50
|
+
state.exportOk += 1;
|
|
51
|
+
state.lastError = null;
|
|
52
|
+
state.codes = state.codes.filter((c) => c !== 'export_fail');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
state.exportFail += 1;
|
|
56
|
+
state.lastError = error || 'export_failed';
|
|
57
|
+
if (!state.codes.includes('export_fail'))
|
|
58
|
+
state.codes.push('export_fail');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function collectorModeFromEnv() {
|
|
62
|
+
const raw = String(process.env.NETGREENER_COLLECTOR_MODE || '')
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
return raw || null;
|
|
66
|
+
}
|
|
67
|
+
export function buildRuntimeHealthV0() {
|
|
68
|
+
const failRate = state.exportAttempts > 0 ? state.exportFail / state.exportAttempts : 0;
|
|
69
|
+
let status = 'unknown';
|
|
70
|
+
const codes = [...state.codes];
|
|
71
|
+
const remediation = [];
|
|
72
|
+
if (!state.hooksActive) {
|
|
73
|
+
status = 'unknown';
|
|
74
|
+
}
|
|
75
|
+
else if (state.exportFail > 0 && failRate >= 0.5) {
|
|
76
|
+
status = 'conflict';
|
|
77
|
+
remediation.push('Check NETGREENER_API_URL / NETGREENER_TOKEN (direct) or NETGREENER_COLLECTOR_ENDPOINT (collector).');
|
|
78
|
+
}
|
|
79
|
+
else if (state.exportFail > 0 || state.exportSkippedInFlight > 0) {
|
|
80
|
+
status = 'degraded';
|
|
81
|
+
if (state.exportSkippedInFlight > 0) {
|
|
82
|
+
codes.push('export_skipped_in_flight');
|
|
83
|
+
remediation.push('A flush was skipped while another was in flight — window data is retried on failure merge; reduce flush contention if drops grow.');
|
|
84
|
+
}
|
|
85
|
+
if (state.exportFail > 0) {
|
|
86
|
+
remediation.push('Inspect prior export errors; failed windows are restored into the aggregator for retry.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (state.exportOk > 0 || state.hooksActive) {
|
|
90
|
+
status = 'ok';
|
|
91
|
+
}
|
|
92
|
+
let detail = 'Node Runtime hooks active.';
|
|
93
|
+
if (state.lastError) {
|
|
94
|
+
detail = `Last export error: ${state.lastError}`;
|
|
95
|
+
}
|
|
96
|
+
else if (state.exportSkippedInFlight > 0) {
|
|
97
|
+
detail = `Hooks active; ${state.exportSkippedInFlight} in-flight flush skip(s).`;
|
|
98
|
+
}
|
|
99
|
+
else if (status === 'ok') {
|
|
100
|
+
detail = 'Node Runtime hooks active; exports healthy.';
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
schema_version: 1,
|
|
104
|
+
status,
|
|
105
|
+
codes: [...new Set(codes)],
|
|
106
|
+
detail,
|
|
107
|
+
remediation,
|
|
108
|
+
bootstrap: state.bootstrap,
|
|
109
|
+
hooks_active: state.hooksActive,
|
|
110
|
+
process_kind: state.processKind,
|
|
111
|
+
collector_mode: collectorModeFromEnv(),
|
|
112
|
+
export_mode: state.exportMode,
|
|
113
|
+
export_attempts: state.exportAttempts,
|
|
114
|
+
export_ok: state.exportOk,
|
|
115
|
+
export_fail: state.exportFail,
|
|
116
|
+
export_skipped_in_flight: state.exportSkippedInFlight,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function mergeHealthIntoMetadata(meta, health = buildRuntimeHealthV0()) {
|
|
120
|
+
meta.runtime_health_v0 = health;
|
|
121
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serverless / thin-flush platform hints (N3 mode docs).
|
|
3
|
+
*
|
|
4
|
+
* Detection only — does **not** flip ``NETGREENER_EXPORT_MODE`` by itself.
|
|
5
|
+
* Long-running Node stays on default ``direct`` (or opt-in ``collector``).
|
|
6
|
+
* Callers set ``NETGREENER_EXPORT_MODE=thin`` or ``NETGREENER_FLUSH_MODE=thin``
|
|
7
|
+
* explicitly when a durable sibling collector is impossible.
|
|
8
|
+
*/
|
|
9
|
+
export type ServerlessPlatformSignal = 'aws_lambda' | 'azure_functions' | 'google_cloud_functions' | 'vercel' | 'netlify';
|
|
10
|
+
export type ServerlessPlatformHint = {
|
|
11
|
+
/** True when any known serverless host signal is present. */
|
|
12
|
+
detected: boolean;
|
|
13
|
+
signals: ServerlessPlatformSignal[];
|
|
14
|
+
/**
|
|
15
|
+
* Suggested export mode when a sibling collector cannot run.
|
|
16
|
+
* Never applied automatically by this helper.
|
|
17
|
+
*/
|
|
18
|
+
suggestedExportMode: 'thin' | null;
|
|
19
|
+
notes: string[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Read well-known platform env vars. Additive / informational only.
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectServerlessPlatformSignals(env?: NodeJS.ProcessEnv): ServerlessPlatformHint;
|
|
25
|
+
/** True when ``NETGREENER_FLUSH_MODE`` requests thin (alias of export mode thin). */
|
|
26
|
+
export declare function flushModeRequestsThin(env?: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serverless / thin-flush platform hints (N3 mode docs).
|
|
3
|
+
*
|
|
4
|
+
* Detection only — does **not** flip ``NETGREENER_EXPORT_MODE`` by itself.
|
|
5
|
+
* Long-running Node stays on default ``direct`` (or opt-in ``collector``).
|
|
6
|
+
* Callers set ``NETGREENER_EXPORT_MODE=thin`` or ``NETGREENER_FLUSH_MODE=thin``
|
|
7
|
+
* explicitly when a durable sibling collector is impossible.
|
|
8
|
+
*/
|
|
9
|
+
function present(env, key) {
|
|
10
|
+
return Boolean(String(env[key] || '').trim());
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Read well-known platform env vars. Additive / informational only.
|
|
14
|
+
*/
|
|
15
|
+
export function detectServerlessPlatformSignals(env = process.env) {
|
|
16
|
+
const signals = [];
|
|
17
|
+
const notes = [];
|
|
18
|
+
if (present(env, 'AWS_LAMBDA_FUNCTION_NAME')) {
|
|
19
|
+
signals.push('aws_lambda');
|
|
20
|
+
}
|
|
21
|
+
if (present(env, 'FUNCTIONS_WORKER_RUNTIME') || present(env, 'AZURE_FUNCTIONS_ENVIRONMENT')) {
|
|
22
|
+
signals.push('azure_functions');
|
|
23
|
+
}
|
|
24
|
+
if (present(env, 'FUNCTION_TARGET') || present(env, 'FUNCTION_SIGNATURE_TYPE')) {
|
|
25
|
+
signals.push('google_cloud_functions');
|
|
26
|
+
}
|
|
27
|
+
if (env.VERCEL === '1' || present(env, 'VERCEL_ENV')) {
|
|
28
|
+
signals.push('vercel');
|
|
29
|
+
}
|
|
30
|
+
if (present(env, 'NETLIFY') || present(env, 'NETLIFY_DEV')) {
|
|
31
|
+
signals.push('netlify');
|
|
32
|
+
}
|
|
33
|
+
const unique = [...new Set(signals)];
|
|
34
|
+
const detected = unique.length > 0;
|
|
35
|
+
if (detected) {
|
|
36
|
+
notes.push('serverless host signal present; set NETGREENER_EXPORT_MODE=thin or NETGREENER_FLUSH_MODE=thin explicitly — auto-cutover is not applied');
|
|
37
|
+
notes.push('flush best-effort at invoke end (runtime.flush / shutdown); no durable sibling spool in thin mode');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
detected,
|
|
41
|
+
signals: unique,
|
|
42
|
+
suggestedExportMode: detected ? 'thin' : null,
|
|
43
|
+
notes,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** True when ``NETGREENER_FLUSH_MODE`` requests thin (alias of export mode thin). */
|
|
47
|
+
export function flushModeRequestsThin(env = process.env) {
|
|
48
|
+
const raw = String(env.NETGREENER_FLUSH_MODE || '')
|
|
49
|
+
.trim()
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
return raw === 'thin';
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Downstream B2B customer company id (not NetGreener org, not end-user). */
|
|
2
|
+
export type TenantId = string;
|
|
3
|
+
export type TenantContext = {
|
|
4
|
+
tenantId: TenantId;
|
|
5
|
+
tenantSource: string;
|
|
6
|
+
tenantLabel?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Mutable ALS cell so ``setTenantContext`` (manual auth) updates the same
|
|
10
|
+
* object Express holds for finish/close after the request ALS exits.
|
|
11
|
+
*/
|
|
12
|
+
export type TenantStore = {
|
|
13
|
+
current: TenantContext | null;
|
|
14
|
+
};
|
|
15
|
+
export declare function normalizeTenantId(value: unknown): TenantId | null;
|
|
16
|
+
export type TenantConfig = {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
source: string;
|
|
19
|
+
claim: string;
|
|
20
|
+
header: string;
|
|
21
|
+
pathRegex: string | null;
|
|
22
|
+
taskKwarg: string;
|
|
23
|
+
labelClaim: string | null;
|
|
24
|
+
};
|
|
25
|
+
export declare function loadTenantConfig(env?: NodeJS.ProcessEnv): TenantConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Build tenant config from effective RuntimeConfig (programmatic opts.config).
|
|
28
|
+
* Prefer this over ambient ``loadTenantConfig()`` when a runtime is already loaded.
|
|
29
|
+
*/
|
|
30
|
+
export declare function tenantConfigFromRuntime(config: {
|
|
31
|
+
tenantSource: string;
|
|
32
|
+
tenantClaim: string;
|
|
33
|
+
tenantHeader: string;
|
|
34
|
+
tenantPathRegex: string | null;
|
|
35
|
+
tenantTaskKwarg: string;
|
|
36
|
+
tenantLabelClaim: string | null;
|
|
37
|
+
}): TenantConfig;
|
|
38
|
+
export declare function resolveTenantFromHttpRequest(req: {
|
|
39
|
+
headers?: Record<string, unknown>;
|
|
40
|
+
path?: string;
|
|
41
|
+
url?: string;
|
|
42
|
+
}, config: TenantConfig): TenantContext | null;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve tenant from queue job payload (Celery kwargs / BullMQ job.data).
|
|
45
|
+
* Requires ``NETGREENER_TENANT_SOURCE=task_kwarg``.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveTenantFromTaskData(data: Record<string, unknown> | null | undefined, config: TenantConfig): TenantContext | null;
|
|
48
|
+
export declare function runWithTenantContext<T>(ctx: TenantContext | null, fn: () => T): T;
|
|
49
|
+
/**
|
|
50
|
+
* Enter a mutable tenant cell for the remainder of this async chain (Fastify
|
|
51
|
+
* ``onRequest`` → route). Prefer ``runWithTenantContext`` when the whole
|
|
52
|
+
* handler runs inside one callback (Express ``next``).
|
|
53
|
+
*/
|
|
54
|
+
export declare function enterTenantStore(ctx: TenantContext | null): TenantStore;
|
|
55
|
+
/** Active mutable tenant cell for this async context (if any). */
|
|
56
|
+
export declare function getTenantStore(): TenantStore | undefined;
|
|
57
|
+
export declare function getTenantContext(): TenantContext | null;
|
|
58
|
+
/**
|
|
59
|
+
* Bind tenant for the current request (e.g. after auth).
|
|
60
|
+
* Mutates the active store in place so finish/close still see the binding
|
|
61
|
+
* after the middleware ALS callback returns.
|
|
62
|
+
*/
|
|
63
|
+
export declare function setTenantContext(ctx: TenantContext): void;
|
|
64
|
+
export declare function collectWindowTenantIds(runtimeV0?: {
|
|
65
|
+
by_tenant?: Record<string, unknown>;
|
|
66
|
+
} | null, externalV0?: {
|
|
67
|
+
by_tenant?: Record<string, unknown>;
|
|
68
|
+
} | null): Set<TenantId>;
|
|
69
|
+
export declare function attachWindowTenantToRunContext(runContext: Record<string, unknown>, opts: {
|
|
70
|
+
runtimeV0?: {
|
|
71
|
+
by_tenant?: Record<string, unknown>;
|
|
72
|
+
} | null;
|
|
73
|
+
externalV0?: {
|
|
74
|
+
by_tenant?: Record<string, unknown>;
|
|
75
|
+
} | null;
|
|
76
|
+
tenantConfig: TenantConfig;
|
|
77
|
+
activeContext?: TenantContext | null;
|
|
78
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
const TENANT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
3
|
+
const storage = new AsyncLocalStorage();
|
|
4
|
+
export function normalizeTenantId(value) {
|
|
5
|
+
if (value === null || value === undefined)
|
|
6
|
+
return null;
|
|
7
|
+
const cleaned = String(value).trim();
|
|
8
|
+
if (!cleaned || cleaned.includes('@'))
|
|
9
|
+
return null;
|
|
10
|
+
const clipped = cleaned.length > 128 ? cleaned.slice(0, 128) : cleaned;
|
|
11
|
+
return TENANT_ID_RE.test(clipped) ? clipped : null;
|
|
12
|
+
}
|
|
13
|
+
function tenantConfigFromSource(fields) {
|
|
14
|
+
const source = (fields.source || 'none').trim().toLowerCase();
|
|
15
|
+
const enabled = !['', 'none', 'off', '0', 'false'].includes(source);
|
|
16
|
+
return {
|
|
17
|
+
enabled,
|
|
18
|
+
source,
|
|
19
|
+
claim: (fields.claim || 'organization_id').trim(),
|
|
20
|
+
header: (fields.header || 'X-Organization-Id').trim(),
|
|
21
|
+
pathRegex: (fields.pathRegex || '').trim() || null,
|
|
22
|
+
taskKwarg: (fields.taskKwarg || 'organization_id').trim(),
|
|
23
|
+
labelClaim: (fields.labelClaim || '').trim() || null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function loadTenantConfig(env = process.env) {
|
|
27
|
+
return tenantConfigFromSource({
|
|
28
|
+
source: env.NETGREENER_TENANT_SOURCE || 'none',
|
|
29
|
+
claim: env.NETGREENER_TENANT_CLAIM || 'organization_id',
|
|
30
|
+
header: env.NETGREENER_TENANT_HEADER || 'X-Organization-Id',
|
|
31
|
+
pathRegex: (env.NETGREENER_TENANT_PATH_REGEX || '').trim() || null,
|
|
32
|
+
taskKwarg: env.NETGREENER_TENANT_TASK_KWARG || 'organization_id',
|
|
33
|
+
labelClaim: (env.NETGREENER_TENANT_LABEL_CLAIM || '').trim() || null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build tenant config from effective RuntimeConfig (programmatic opts.config).
|
|
38
|
+
* Prefer this over ambient ``loadTenantConfig()`` when a runtime is already loaded.
|
|
39
|
+
*/
|
|
40
|
+
export function tenantConfigFromRuntime(config) {
|
|
41
|
+
return tenantConfigFromSource({
|
|
42
|
+
source: config.tenantSource,
|
|
43
|
+
claim: config.tenantClaim,
|
|
44
|
+
header: config.tenantHeader,
|
|
45
|
+
pathRegex: config.tenantPathRegex,
|
|
46
|
+
taskKwarg: config.tenantTaskKwarg,
|
|
47
|
+
labelClaim: config.tenantLabelClaim,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function configuredTenantSourceLabel(config) {
|
|
51
|
+
if (!config.enabled)
|
|
52
|
+
return null;
|
|
53
|
+
if (config.source === 'jwt_claim')
|
|
54
|
+
return `jwt_claim:${config.claim}`;
|
|
55
|
+
if (config.source === 'header')
|
|
56
|
+
return `header:${config.header}`;
|
|
57
|
+
if (config.source === 'path_regex' && config.pathRegex)
|
|
58
|
+
return `path_regex:${config.pathRegex}`;
|
|
59
|
+
if (config.source === 'task_kwarg')
|
|
60
|
+
return `task_kwarg:${config.taskKwarg}`;
|
|
61
|
+
if (config.source === 'manual')
|
|
62
|
+
return 'manual';
|
|
63
|
+
return config.source;
|
|
64
|
+
}
|
|
65
|
+
function headerValue(req, name) {
|
|
66
|
+
const headers = req.headers || {};
|
|
67
|
+
const target = name.toLowerCase();
|
|
68
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
69
|
+
if (key.toLowerCase() !== target)
|
|
70
|
+
continue;
|
|
71
|
+
if (Array.isArray(value))
|
|
72
|
+
return String(value[0] ?? '').trim() || null;
|
|
73
|
+
return String(value ?? '').trim() || null;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function jwtPayloadUnverified(authorization) {
|
|
78
|
+
if (!authorization)
|
|
79
|
+
return null;
|
|
80
|
+
const auth = authorization.trim();
|
|
81
|
+
if (!auth.toLowerCase().startsWith('bearer '))
|
|
82
|
+
return null;
|
|
83
|
+
const token = auth.slice(7).trim();
|
|
84
|
+
const parts = token.split('.');
|
|
85
|
+
if (parts.length < 2)
|
|
86
|
+
return null;
|
|
87
|
+
try {
|
|
88
|
+
const segment = parts[1];
|
|
89
|
+
const pad = '='.repeat((4 - (segment.length % 4)) % 4);
|
|
90
|
+
const raw = Buffer.from(segment + pad, 'base64url').toString('utf8');
|
|
91
|
+
const payload = JSON.parse(raw);
|
|
92
|
+
return payload && typeof payload === 'object' ? payload : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function claimValue(payload, claim) {
|
|
99
|
+
if (claim in payload)
|
|
100
|
+
return payload[claim];
|
|
101
|
+
if (claim.includes('.')) {
|
|
102
|
+
let cur = payload;
|
|
103
|
+
for (const part of claim.split('.')) {
|
|
104
|
+
if (!cur || typeof cur !== 'object' || !(part in cur)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
cur = cur[part];
|
|
108
|
+
}
|
|
109
|
+
return cur;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
export function resolveTenantFromHttpRequest(req, config) {
|
|
114
|
+
if (!config.enabled || config.source === 'manual')
|
|
115
|
+
return null;
|
|
116
|
+
if (config.source === 'jwt_claim') {
|
|
117
|
+
const payload = jwtPayloadUnverified(headerValue(req, 'authorization'));
|
|
118
|
+
if (!payload)
|
|
119
|
+
return null;
|
|
120
|
+
const tenantId = normalizeTenantId(claimValue(payload, config.claim));
|
|
121
|
+
if (!tenantId)
|
|
122
|
+
return null;
|
|
123
|
+
const labelRaw = config.labelClaim ? claimValue(payload, config.labelClaim) : null;
|
|
124
|
+
return {
|
|
125
|
+
tenantId,
|
|
126
|
+
tenantSource: `jwt_claim:${config.claim}`,
|
|
127
|
+
...(labelRaw != null ? { tenantLabel: String(labelRaw).slice(0, 128) } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (config.source === 'header') {
|
|
131
|
+
const tenantId = normalizeTenantId(headerValue(req, config.header));
|
|
132
|
+
if (!tenantId)
|
|
133
|
+
return null;
|
|
134
|
+
return { tenantId, tenantSource: `header:${config.header}` };
|
|
135
|
+
}
|
|
136
|
+
if (config.source === 'path_regex' && config.pathRegex) {
|
|
137
|
+
const path = (req.path || req.url || '').split('?')[0] || '';
|
|
138
|
+
try {
|
|
139
|
+
const re = new RegExp(config.pathRegex);
|
|
140
|
+
const match = re.exec(path);
|
|
141
|
+
if (!match)
|
|
142
|
+
return null;
|
|
143
|
+
const captured = match[1] ?? match[0];
|
|
144
|
+
const tenantId = normalizeTenantId(captured);
|
|
145
|
+
if (!tenantId)
|
|
146
|
+
return null;
|
|
147
|
+
return { tenantId, tenantSource: `path_regex:${config.pathRegex}` };
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolve tenant from queue job payload (Celery kwargs / BullMQ job.data).
|
|
157
|
+
* Requires ``NETGREENER_TENANT_SOURCE=task_kwarg``.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveTenantFromTaskData(data, config) {
|
|
160
|
+
if (!config.enabled || config.source !== 'task_kwarg')
|
|
161
|
+
return null;
|
|
162
|
+
const key = config.taskKwarg || 'organization_id';
|
|
163
|
+
const tenantId = normalizeTenantId((data || {})[key]);
|
|
164
|
+
if (!tenantId)
|
|
165
|
+
return null;
|
|
166
|
+
return { tenantId, tenantSource: `task_kwarg:${key}` };
|
|
167
|
+
}
|
|
168
|
+
export function runWithTenantContext(ctx, fn) {
|
|
169
|
+
return storage.run({ current: ctx }, fn);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Enter a mutable tenant cell for the remainder of this async chain (Fastify
|
|
173
|
+
* ``onRequest`` → route). Prefer ``runWithTenantContext`` when the whole
|
|
174
|
+
* handler runs inside one callback (Express ``next``).
|
|
175
|
+
*/
|
|
176
|
+
export function enterTenantStore(ctx) {
|
|
177
|
+
const store = { current: ctx };
|
|
178
|
+
storage.enterWith(store);
|
|
179
|
+
return store;
|
|
180
|
+
}
|
|
181
|
+
/** Active mutable tenant cell for this async context (if any). */
|
|
182
|
+
export function getTenantStore() {
|
|
183
|
+
return storage.getStore();
|
|
184
|
+
}
|
|
185
|
+
export function getTenantContext() {
|
|
186
|
+
return storage.getStore()?.current ?? null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Bind tenant for the current request (e.g. after auth).
|
|
190
|
+
* Mutates the active store in place so finish/close still see the binding
|
|
191
|
+
* after the middleware ALS callback returns.
|
|
192
|
+
*/
|
|
193
|
+
export function setTenantContext(ctx) {
|
|
194
|
+
const store = storage.getStore();
|
|
195
|
+
if (store) {
|
|
196
|
+
store.current = ctx;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
storage.enterWith({ current: ctx });
|
|
200
|
+
}
|
|
201
|
+
export function collectWindowTenantIds(runtimeV0, externalV0) {
|
|
202
|
+
const ids = new Set();
|
|
203
|
+
for (const block of [runtimeV0, externalV0]) {
|
|
204
|
+
if (!block?.by_tenant)
|
|
205
|
+
continue;
|
|
206
|
+
for (const key of Object.keys(block.by_tenant)) {
|
|
207
|
+
if (key)
|
|
208
|
+
ids.add(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return ids;
|
|
212
|
+
}
|
|
213
|
+
export function attachWindowTenantToRunContext(runContext, opts) {
|
|
214
|
+
const tenantIds = collectWindowTenantIds(opts.runtimeV0, opts.externalV0);
|
|
215
|
+
if (tenantIds.size !== 1)
|
|
216
|
+
return runContext;
|
|
217
|
+
const tenantId = [...tenantIds][0];
|
|
218
|
+
runContext.tenant_id = tenantId;
|
|
219
|
+
const source = configuredTenantSourceLabel(opts.tenantConfig) || opts.activeContext?.tenantSource;
|
|
220
|
+
if (source)
|
|
221
|
+
runContext.tenant_source = source;
|
|
222
|
+
const label = opts.activeContext?.tenantLabel;
|
|
223
|
+
if (label)
|
|
224
|
+
runContext.tenant_label = label;
|
|
225
|
+
return runContext;
|
|
226
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Default tenant attribution fields for tests and partial RuntimeConfig mocks. */
|
|
2
|
+
export declare const DEFAULT_TENANT_RUNTIME_FIELDS: {
|
|
3
|
+
readonly tenantSource: "none";
|
|
4
|
+
readonly tenantClaim: "organization_id";
|
|
5
|
+
readonly tenantHeader: "X-Organization-Id";
|
|
6
|
+
readonly tenantPathRegex: null;
|
|
7
|
+
readonly tenantTaskKwarg: "organization_id";
|
|
8
|
+
readonly tenantLabelClaim: null;
|
|
9
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Default tenant attribution fields for tests and partial RuntimeConfig mocks. */
|
|
2
|
+
export const DEFAULT_TENANT_RUNTIME_FIELDS = {
|
|
3
|
+
tenantSource: 'none',
|
|
4
|
+
tenantClaim: 'organization_id',
|
|
5
|
+
tenantHeader: 'X-Organization-Id',
|
|
6
|
+
tenantPathRegex: null,
|
|
7
|
+
tenantTaskKwarg: 'organization_id',
|
|
8
|
+
tenantLabelClaim: null,
|
|
9
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Shared shapes for NetGreener runtime session_metadata (Node emitter). */
|
|
2
|
+
export type DurationMs = {
|
|
3
|
+
avg: number;
|
|
4
|
+
max: number;
|
|
5
|
+
};
|
|
6
|
+
export type ServiceRuntimeUnit = {
|
|
7
|
+
service_unit: string;
|
|
8
|
+
unit_type: string;
|
|
9
|
+
calls: number;
|
|
10
|
+
errors: number;
|
|
11
|
+
cpu_seconds_total: number;
|
|
12
|
+
energy_kwh: number;
|
|
13
|
+
carbon_g: number;
|
|
14
|
+
duration_ms: DurationMs;
|
|
15
|
+
peak_rss_kb_max?: number;
|
|
16
|
+
cpu_percent_hours?: number;
|
|
17
|
+
ram_gb_hours?: number;
|
|
18
|
+
process_gpu_hours?: number;
|
|
19
|
+
};
|
|
20
|
+
export type AllocationReconciliationV0 = {
|
|
21
|
+
schema_version: number;
|
|
22
|
+
window_energy_kwh: number;
|
|
23
|
+
allocated_energy_kwh: number;
|
|
24
|
+
residual_energy_kwh: number;
|
|
25
|
+
tolerance_kwh: number;
|
|
26
|
+
passed: boolean;
|
|
27
|
+
scope: string;
|
|
28
|
+
endpoint_capture_coverage: number | null;
|
|
29
|
+
capture_completeness: string;
|
|
30
|
+
};
|
|
31
|
+
/** Downstream B2B customer company id (not NetGreener org, not end-user). */
|
|
32
|
+
export type TenantId = string;
|
|
33
|
+
export type ServiceRuntimeTenantBucket = {
|
|
34
|
+
tenant_id: TenantId;
|
|
35
|
+
calls: number;
|
|
36
|
+
errors: number;
|
|
37
|
+
units: ServiceRuntimeUnit[];
|
|
38
|
+
};
|
|
39
|
+
export type ServiceRuntimeV0 = {
|
|
40
|
+
framework?: string | null;
|
|
41
|
+
collector: string;
|
|
42
|
+
window_seconds: number;
|
|
43
|
+
attribution: string;
|
|
44
|
+
accuracy: string;
|
|
45
|
+
units: ServiceRuntimeUnit[];
|
|
46
|
+
by_tenant?: Record<TenantId, ServiceRuntimeTenantBucket>;
|
|
47
|
+
allocation_reconciliation_v0?: AllocationReconciliationV0;
|
|
48
|
+
};
|
|
49
|
+
export type ExternalApiModelBucket = {
|
|
50
|
+
model: string;
|
|
51
|
+
calls: number;
|
|
52
|
+
errors: number;
|
|
53
|
+
calls_with_usage?: number;
|
|
54
|
+
input_tokens?: number;
|
|
55
|
+
output_tokens?: number;
|
|
56
|
+
total_tokens?: number;
|
|
57
|
+
};
|
|
58
|
+
export type ExternalApiProvider = {
|
|
59
|
+
provider_key: string;
|
|
60
|
+
calls: number;
|
|
61
|
+
errors: number;
|
|
62
|
+
calls_with_usage?: number;
|
|
63
|
+
duration_ms_avg?: number;
|
|
64
|
+
hosts?: string[];
|
|
65
|
+
input_tokens?: number;
|
|
66
|
+
output_tokens?: number;
|
|
67
|
+
total_tokens?: number;
|
|
68
|
+
models?: string[];
|
|
69
|
+
by_model?: ExternalApiModelBucket[];
|
|
70
|
+
};
|
|
71
|
+
export type ExternalApiTenantBucket = {
|
|
72
|
+
tenant_id: TenantId;
|
|
73
|
+
calls: number;
|
|
74
|
+
errors: number;
|
|
75
|
+
providers: ExternalApiProvider[];
|
|
76
|
+
};
|
|
77
|
+
export type ExternalApiV0 = {
|
|
78
|
+
collector: string;
|
|
79
|
+
providers: ExternalApiProvider[];
|
|
80
|
+
by_service_unit?: Record<string, unknown>;
|
|
81
|
+
by_tenant?: Record<TenantId, ExternalApiTenantBucket>;
|
|
82
|
+
};
|
|
83
|
+
export type EvidenceMetric = {
|
|
84
|
+
metric: string;
|
|
85
|
+
grade: string;
|
|
86
|
+
reasons: string[];
|
|
87
|
+
};
|
|
88
|
+
export type EvidenceGrades = {
|
|
89
|
+
schema_version: number;
|
|
90
|
+
policy_version: string;
|
|
91
|
+
metrics: {
|
|
92
|
+
local_energy: EvidenceMetric;
|
|
93
|
+
local_carbon: EvidenceMetric;
|
|
94
|
+
};
|
|
95
|
+
combined_floor_grade: string;
|
|
96
|
+
notes?: string[];
|
|
97
|
+
};
|
|
98
|
+
export type MeasurementProvenance = {
|
|
99
|
+
cpu_source?: string;
|
|
100
|
+
memory_source?: string;
|
|
101
|
+
energy_model?: string;
|
|
102
|
+
gpu_scope?: string;
|
|
103
|
+
gpu_source?: string;
|
|
104
|
+
carbon_factor?: {
|
|
105
|
+
value: number | null;
|
|
106
|
+
unit?: string;
|
|
107
|
+
source?: string;
|
|
108
|
+
};
|
|
109
|
+
evidence_grades?: EvidenceGrades;
|
|
110
|
+
[key: string]: unknown;
|
|
111
|
+
};
|
|
112
|
+
export type RunContext = {
|
|
113
|
+
deploy_environment?: string;
|
|
114
|
+
client?: string;
|
|
115
|
+
release_tag?: string;
|
|
116
|
+
/** Downstream customer company org id when tenant attribution is enabled. */
|
|
117
|
+
tenant_id?: TenantId;
|
|
118
|
+
/** e.g. jwt_claim:organization_id */
|
|
119
|
+
tenant_source?: string;
|
|
120
|
+
/** Optional display label; not a billing key. */
|
|
121
|
+
tenant_label?: string;
|
|
122
|
+
[key: string]: unknown;
|
|
123
|
+
};
|
|
124
|
+
/** session_metadata fragment Node must be able to upload. */
|
|
125
|
+
export type RuntimeSessionMetadata = {
|
|
126
|
+
run_context?: RunContext;
|
|
127
|
+
service_runtime_v0?: ServiceRuntimeV0;
|
|
128
|
+
external_api_v0?: ExternalApiV0;
|
|
129
|
+
measurement_provenance?: MeasurementProvenance;
|
|
130
|
+
[key: string]: unknown;
|
|
131
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RuntimeConfig } from './config.js';
|
|
2
|
+
import type { RuntimeSessionMetadata } from './types.js';
|
|
3
|
+
export type RunSessionCreatePayload = {
|
|
4
|
+
project_id: number;
|
|
5
|
+
start_time: string;
|
|
6
|
+
end_time: string;
|
|
7
|
+
server_name?: string | null;
|
|
8
|
+
energy_kwh?: number;
|
|
9
|
+
runtime_window_id: string;
|
|
10
|
+
session_metadata: RuntimeSessionMetadata;
|
|
11
|
+
};
|
|
12
|
+
export type UploadResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
status: number;
|
|
15
|
+
body: unknown;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
status?: number;
|
|
20
|
+
error: string;
|
|
21
|
+
body?: unknown;
|
|
22
|
+
};
|
|
23
|
+
export declare function uploadRunSession(config: RuntimeConfig, payload: RunSessionCreatePayload, fetchImpl?: typeof fetch): Promise<UploadResult>;
|