@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,1167 @@
1
+ /**
2
+ * Outbound external-API call metering (N2).
3
+ *
4
+ * Additive ``external_api_v0`` for Node flushes — usage metadata only
5
+ * (no prompts/bodies). Soft-wraps:
6
+ * - global ``fetch`` (primary)
7
+ * - ``axios`` Axios.prototype.request when axios is resolvable (optional peer)
8
+ * - ``undici.request`` / Dispatcher.request when undici is resolvable
9
+ *
10
+ * Uploads restore unwrapped ``fetch``. Axios→fetch adapter paths skip the
11
+ * fetch meter via ALS so the same call is not double-counted.
12
+ */
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { createRequire } from 'node:module';
15
+ import { getTenantContext } from './tenantContext.js';
16
+ import { attributionServiceUnit } from './requestContext.js';
17
+ const requireFromHere = createRequire(import.meta.url);
18
+ /**
19
+ * Nested transport ownership — only the outermost wrap records.
20
+ * axios → (optional fetch adapter) → undici must count once.
21
+ */
22
+ const outboundMeterOwnerAls = new AsyncLocalStorage();
23
+ const MAX_PROVIDERS = 200;
24
+ const MAX_MODELS_PER_PROVIDER = 16;
25
+ const MAX_USAGE_PARSE_BYTES = 256 * 1024;
26
+ const AZURE_DEPLOYMENT_RE = /\/openai\/deployments\/([^/?#]+)/i;
27
+ /** Ordered host-substring → provider_key (first match wins). Mirrors Python. */
28
+ const HOST_PROVIDER_PATTERNS = [
29
+ ['openai.azure.com', 'openai'],
30
+ ['openai.com', 'openai'],
31
+ ['anthropic.com', 'anthropic'],
32
+ ['stripe.com', 'stripe'],
33
+ ['twilio.com', 'twilio'],
34
+ ['sendgrid.com', 'sendgrid'],
35
+ ['sendgrid.net', 'sendgrid'],
36
+ ['cognitiveservices.azure.com', 'azure_document_intelligence'],
37
+ ['cognitive.microsoft.com', 'azure_document_intelligence'],
38
+ ['textract.', 'aws_textract'],
39
+ ['documentai.googleapis.com', 'google_document_ai'],
40
+ ['vision.googleapis.com', 'google_document_ai'],
41
+ ['generativelanguage.googleapis.com', 'google'],
42
+ ['googleapis.com', 'google'],
43
+ ['cohere.ai', 'cohere'],
44
+ ['cohere.com', 'cohere'],
45
+ ['huggingface.co', 'huggingface'],
46
+ ['amazonaws.com', 'boto3'],
47
+ ];
48
+ function registeredDomain(host) {
49
+ const parts = host.split('.').filter(Boolean);
50
+ if (parts.length >= 2)
51
+ return parts.slice(-2).join('.');
52
+ return host;
53
+ }
54
+ export function providerForHost(host) {
55
+ const h = (host || '').toLowerCase().trim();
56
+ for (const [needle, provider] of HOST_PROVIDER_PATTERNS) {
57
+ if (h.includes(needle))
58
+ return provider;
59
+ }
60
+ return registeredDomain(h) || 'unknown';
61
+ }
62
+ export function hostFromUrl(url) {
63
+ try {
64
+ return new URL(String(url)).hostname || '';
65
+ }
66
+ catch {
67
+ return '';
68
+ }
69
+ }
70
+ export function modelFromUrl(url) {
71
+ try {
72
+ const match = AZURE_DEPLOYMENT_RE.exec(String(url || ''));
73
+ const name = match?.[1]?.trim().slice(0, 128);
74
+ return name || null;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function isPrivateOrLocalHost(host) {
81
+ if (!host)
82
+ return true;
83
+ const h = host.toLowerCase();
84
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.local')) {
85
+ return true;
86
+ }
87
+ if (h.startsWith('10.') ||
88
+ h.startsWith('192.168.') ||
89
+ h.startsWith('169.254.') ||
90
+ h.startsWith('0.')) {
91
+ return true;
92
+ }
93
+ // 172.16.0.0 – 172.31.255.255 (string prefix best-effort)
94
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(h))
95
+ return true;
96
+ return false;
97
+ }
98
+ function asTokenInt(value) {
99
+ const n = Number(value);
100
+ if (!Number.isFinite(n) || n < 0)
101
+ return 0;
102
+ return Math.floor(n);
103
+ }
104
+ function pairFromKeys(usage, inKeys, outKeys) {
105
+ if (![...inKeys, ...outKeys].some((k) => k in usage))
106
+ return null;
107
+ let inTok = 0;
108
+ for (const key of inKeys) {
109
+ if (key in usage) {
110
+ inTok = asTokenInt(usage[key]);
111
+ break;
112
+ }
113
+ }
114
+ let outTok = 0;
115
+ for (const key of outKeys) {
116
+ if (key in usage) {
117
+ outTok = asTokenInt(usage[key]);
118
+ break;
119
+ }
120
+ }
121
+ return [inTok, outTok];
122
+ }
123
+ export function tokensFromUsage(usage) {
124
+ const openaiChat = pairFromKeys(usage, ['prompt_tokens'], ['completion_tokens']);
125
+ const openaiResp = pairFromKeys(usage, ['input_tokens'], ['output_tokens']);
126
+ const gemini = pairFromKeys(usage, ['promptTokenCount', 'prompt_token_count'], [
127
+ 'candidatesTokenCount',
128
+ 'candidates_token_count',
129
+ 'outputTokenCount',
130
+ 'output_token_count',
131
+ ]);
132
+ const scored = [openaiChat, openaiResp, gemini].filter((p) => Boolean(p && (p[0] || p[1])));
133
+ let inTok = 0;
134
+ let outTok = 0;
135
+ if (scored.length) {
136
+ ;
137
+ [inTok, outTok] = scored.reduce((a, b) => (a[0] + a[1] >= b[0] + b[1] ? a : b));
138
+ }
139
+ else {
140
+ for (const nestedKey of ['billed_units', 'tokens']) {
141
+ const nested = usage[nestedKey];
142
+ if (nested && typeof nested === 'object') {
143
+ const [ni, no] = tokensFromUsage(nested);
144
+ if (ni || no) {
145
+ inTok = ni;
146
+ outTok = no;
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ let total = 0;
153
+ for (const totalKey of ['total_tokens', 'totalTokenCount', 'total_token_count']) {
154
+ if (totalKey in usage) {
155
+ total = asTokenInt(usage[totalKey]);
156
+ break;
157
+ }
158
+ }
159
+ if (total && total > inTok + outTok) {
160
+ if (inTok || outTok)
161
+ outTok = Math.max(outTok, total - inTok);
162
+ else
163
+ inTok = total;
164
+ }
165
+ if (inTok || outTok)
166
+ return [inTok, outTok];
167
+ return openaiChat || openaiResp || gemini || [0, 0];
168
+ }
169
+ function usageFromPayload(data) {
170
+ let model = null;
171
+ const modelRaw = data.model ?? data.modelVersion ?? data.model_version;
172
+ if (typeof modelRaw === 'string' && modelRaw.trim()) {
173
+ model = modelRaw.trim().slice(0, 128);
174
+ }
175
+ let bestIn = 0;
176
+ let bestOut = 0;
177
+ for (const usageKey of ['usage', 'usageMetadata', 'usage_metadata']) {
178
+ const usage = data[usageKey];
179
+ if (usage && typeof usage === 'object') {
180
+ const [input, output] = tokensFromUsage(usage);
181
+ if (input + output > bestIn + bestOut) {
182
+ bestIn = input;
183
+ bestOut = output;
184
+ }
185
+ }
186
+ }
187
+ if (bestIn || bestOut)
188
+ return { input: bestIn, output: bestOut, model };
189
+ for (const nestedKey of ['response', 'message']) {
190
+ const nested = data[nestedKey];
191
+ if (nested && typeof nested === 'object') {
192
+ const extracted = usageFromPayload(nested);
193
+ if (extracted.input || extracted.output || extracted.model) {
194
+ return {
195
+ input: extracted.input,
196
+ output: extracted.output,
197
+ model: extracted.model || model,
198
+ };
199
+ }
200
+ }
201
+ }
202
+ return { input: 0, output: 0, model };
203
+ }
204
+ const SSE_TOKEN_KEY_RE = /(?<!\\)"(prompt_tokens|input_tokens|completion_tokens|output_tokens|promptTokenCount|candidatesTokenCount|outputTokenCount|prompt_token_count|candidates_token_count|output_token_count|total_tokens|totalTokenCount|total_token_count)"\s*:\s*(\d+)/g;
205
+ const SSE_IN_KEYS = new Set([
206
+ 'prompt_tokens',
207
+ 'input_tokens',
208
+ 'promptTokenCount',
209
+ 'prompt_token_count',
210
+ ]);
211
+ const SSE_OUT_KEYS = new Set([
212
+ 'completion_tokens',
213
+ 'output_tokens',
214
+ 'candidatesTokenCount',
215
+ 'outputTokenCount',
216
+ 'candidates_token_count',
217
+ 'output_token_count',
218
+ ]);
219
+ export function usageFromSseText(text) {
220
+ if (!text)
221
+ return { input: 0, output: 0, model: null };
222
+ let bestIn = 0;
223
+ let bestOut = 0;
224
+ let model = null;
225
+ for (const rawLine of text.split(/\r?\n/)) {
226
+ let line = rawLine.trim();
227
+ if (!line)
228
+ continue;
229
+ if (line.startsWith('data:'))
230
+ line = line.slice(5).trim();
231
+ if (!line || line === '[DONE]' || (line[0] !== '{' && line[0] !== '['))
232
+ continue;
233
+ try {
234
+ const data = JSON.parse(line);
235
+ if (!data || typeof data !== 'object' || Array.isArray(data))
236
+ continue;
237
+ const extracted = usageFromPayload(data);
238
+ if (extracted.model)
239
+ model = extracted.model;
240
+ if (extracted.input)
241
+ bestIn = extracted.input;
242
+ if (extracted.output)
243
+ bestOut = extracted.output;
244
+ }
245
+ catch {
246
+ continue;
247
+ }
248
+ }
249
+ if (!bestIn && !bestOut) {
250
+ let recoveredIn = 0;
251
+ let recoveredOut = 0;
252
+ let recoveredTotal = 0;
253
+ SSE_TOKEN_KEY_RE.lastIndex = 0;
254
+ let match;
255
+ while ((match = SSE_TOKEN_KEY_RE.exec(text)) !== null) {
256
+ const key = match[1];
257
+ const value = Number(match[2]);
258
+ if (!Number.isFinite(value) || value < 0)
259
+ continue;
260
+ if (SSE_IN_KEYS.has(key))
261
+ recoveredIn = value;
262
+ else if (SSE_OUT_KEYS.has(key))
263
+ recoveredOut = value;
264
+ else
265
+ recoveredTotal = value;
266
+ }
267
+ if (recoveredTotal && recoveredTotal > recoveredIn + recoveredOut) {
268
+ if (recoveredIn || recoveredOut) {
269
+ recoveredOut = Math.max(recoveredOut, recoveredTotal - recoveredIn);
270
+ }
271
+ else {
272
+ recoveredIn = recoveredTotal;
273
+ }
274
+ }
275
+ bestIn = recoveredIn;
276
+ bestOut = recoveredOut;
277
+ }
278
+ if (model == null) {
279
+ const modelMatches = [
280
+ ...text.matchAll(/(?<!\\)"(?:model|modelVersion)"\s*:\s*"([^"\\]{1,128})"/g),
281
+ ];
282
+ if (modelMatches.length) {
283
+ const last = modelMatches[modelMatches.length - 1]?.[1]?.trim();
284
+ if (last)
285
+ model = last;
286
+ }
287
+ }
288
+ return { input: bestIn, output: bestOut, model };
289
+ }
290
+ function isStreamingContentType(response) {
291
+ const ctype = (response.headers.get('content-type') || '').toLowerCase();
292
+ return (ctype.includes('text/event-stream') ||
293
+ ctype.includes('ndjson') ||
294
+ ctype.includes('application/stream+json'));
295
+ }
296
+ export async function extractUsageFromResponse(response, url) {
297
+ let model = modelFromUrl(url);
298
+ try {
299
+ const ctype = (response.headers.get('content-type') || '').toLowerCase();
300
+ const clone = response.clone();
301
+ const buf = Buffer.from(await clone.arrayBuffer());
302
+ if (buf.length === 0)
303
+ return { input: 0, output: 0, model };
304
+ const bounded = buf.length > MAX_USAGE_PARSE_BYTES ? buf.subarray(buf.length - MAX_USAGE_PARSE_BYTES) : buf;
305
+ const text = bounded.toString('utf8');
306
+ const streaming = isStreamingContentType(response) || text.startsWith('data:');
307
+ if (streaming || buf.length > MAX_USAGE_PARSE_BYTES) {
308
+ const extracted = usageFromSseText(text);
309
+ if (extracted.model)
310
+ model = extracted.model;
311
+ return { input: extracted.input, output: extracted.output, model };
312
+ }
313
+ const trimmed = text.trimStart();
314
+ if (ctype.includes('json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
315
+ try {
316
+ const parsed = JSON.parse(text);
317
+ if (parsed && typeof parsed === 'object') {
318
+ const extracted = usageFromPayload(parsed);
319
+ if (extracted.model)
320
+ model = extracted.model;
321
+ if (extracted.input || extracted.output) {
322
+ return { input: extracted.input, output: extracted.output, model };
323
+ }
324
+ }
325
+ }
326
+ catch {
327
+ /* fall through to SSE/field scan */
328
+ }
329
+ }
330
+ const extracted = usageFromSseText(text);
331
+ if (extracted.model)
332
+ model = extracted.model;
333
+ return { input: extracted.input, output: extracted.output, model };
334
+ }
335
+ catch {
336
+ return { input: 0, output: 0, model };
337
+ }
338
+ }
339
+ /**
340
+ * Usage extraction for axios (and similar) bodies already buffered by the client.
341
+ * Does not read streams; returns zeros for stream/Buffer/unknown shapes.
342
+ */
343
+ export function usageFromClientBody(data, url, contentType) {
344
+ const model = modelFromUrl(url);
345
+ if (data == null)
346
+ return { input: 0, output: 0, model };
347
+ if (typeof data === 'object') {
348
+ if (Buffer.isBuffer(data)) {
349
+ const text = data.toString('utf8', Math.max(0, data.length - MAX_USAGE_PARSE_BYTES), data.length);
350
+ const extracted = usageFromSseText(text);
351
+ return {
352
+ input: extracted.input,
353
+ output: extracted.output,
354
+ model: extracted.model || model,
355
+ };
356
+ }
357
+ // Readable / stream — do not consume (privacy + caller ownership).
358
+ if (typeof data.pipe === 'function') {
359
+ return { input: 0, output: 0, model };
360
+ }
361
+ if (!Array.isArray(data)) {
362
+ const extracted = usageFromPayload(data);
363
+ return {
364
+ input: extracted.input,
365
+ output: extracted.output,
366
+ model: extracted.model || model,
367
+ };
368
+ }
369
+ }
370
+ if (typeof data === 'string') {
371
+ const ctype = (contentType || '').toLowerCase();
372
+ const trimmed = data.trimStart();
373
+ const streaming = ctype.includes('text/event-stream') ||
374
+ ctype.includes('ndjson') ||
375
+ ctype.includes('application/stream+json') ||
376
+ trimmed.startsWith('data:');
377
+ if (streaming || data.length > MAX_USAGE_PARSE_BYTES) {
378
+ const slice = data.length > MAX_USAGE_PARSE_BYTES
379
+ ? data.slice(data.length - MAX_USAGE_PARSE_BYTES)
380
+ : data;
381
+ const extracted = usageFromSseText(slice);
382
+ return {
383
+ input: extracted.input,
384
+ output: extracted.output,
385
+ model: extracted.model || model,
386
+ };
387
+ }
388
+ if (ctype.includes('json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
389
+ try {
390
+ const parsed = JSON.parse(data);
391
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
392
+ const extracted = usageFromPayload(parsed);
393
+ return {
394
+ input: extracted.input,
395
+ output: extracted.output,
396
+ model: extracted.model || model,
397
+ };
398
+ }
399
+ }
400
+ catch {
401
+ /* fall through */
402
+ }
403
+ }
404
+ const extracted = usageFromSseText(data);
405
+ return {
406
+ input: extracted.input,
407
+ output: extracted.output,
408
+ model: extracted.model || model,
409
+ };
410
+ }
411
+ return { input: 0, output: 0, model };
412
+ }
413
+ function emptyProvider(key) {
414
+ return {
415
+ provider_key: key,
416
+ calls: 0,
417
+ errors: 0,
418
+ duration_ms_total: 0,
419
+ input_tokens: 0,
420
+ output_tokens: 0,
421
+ calls_with_usage: 0,
422
+ hosts: new Set(),
423
+ models: new Set(),
424
+ by_model: new Map(),
425
+ };
426
+ }
427
+ export class ExternalApiAggregator {
428
+ providers = new Map();
429
+ byServiceUnit = new Map();
430
+ byTenant = new Map();
431
+ excludedHosts = new Set();
432
+ setExcludedHosts(hosts) {
433
+ this.excludedHosts = new Set([...hosts].map((h) => h.toLowerCase().trim()).filter(Boolean));
434
+ }
435
+ isExcluded(host) {
436
+ if (isPrivateOrLocalHost(host))
437
+ return true;
438
+ const h = host.toLowerCase();
439
+ for (const ex of this.excludedHosts) {
440
+ if (h === ex || h === registeredDomain(ex) || h.endsWith(`.${registeredDomain(ex)}`)) {
441
+ return true;
442
+ }
443
+ }
444
+ return false;
445
+ }
446
+ hasData() {
447
+ for (const p of this.providers.values()) {
448
+ if (p.calls > 0)
449
+ return true;
450
+ }
451
+ return false;
452
+ }
453
+ recordProvider(bucket, opts) {
454
+ let provider = opts.provider;
455
+ let p = bucket.get(provider);
456
+ if (!p) {
457
+ if (bucket.size >= MAX_PROVIDERS) {
458
+ provider = '__other__';
459
+ p = bucket.get(provider);
460
+ if (!p) {
461
+ p = emptyProvider(provider);
462
+ bucket.set(provider, p);
463
+ }
464
+ }
465
+ else {
466
+ p = emptyProvider(provider);
467
+ bucket.set(provider, p);
468
+ }
469
+ }
470
+ p.calls += 1;
471
+ if (opts.error)
472
+ p.errors += 1;
473
+ p.duration_ms_total += Math.max(0, opts.durationMs);
474
+ const hasUsage = opts.inputTokens > 0 || opts.outputTokens > 0;
475
+ if (hasUsage)
476
+ p.calls_with_usage += 1;
477
+ if (opts.inputTokens > 0)
478
+ p.input_tokens += opts.inputTokens;
479
+ if (opts.outputTokens > 0)
480
+ p.output_tokens += opts.outputTokens;
481
+ if (opts.host && p.hosts.size < 8)
482
+ p.hosts.add(opts.host.toLowerCase());
483
+ const model = (opts.model || '').trim().slice(0, 128);
484
+ if (!model)
485
+ return;
486
+ if (p.models.size < MAX_MODELS_PER_PROVIDER)
487
+ p.models.add(model);
488
+ let mKey = model;
489
+ let mAgg = p.by_model.get(mKey);
490
+ if (!mAgg) {
491
+ if (p.by_model.size >= MAX_MODELS_PER_PROVIDER) {
492
+ mKey = '__other__';
493
+ mAgg = p.by_model.get(mKey);
494
+ if (!mAgg) {
495
+ mAgg = { model: mKey, calls: 0, errors: 0, input_tokens: 0, output_tokens: 0, calls_with_usage: 0 };
496
+ p.by_model.set(mKey, mAgg);
497
+ }
498
+ }
499
+ else {
500
+ mAgg = { model: mKey, calls: 0, errors: 0, input_tokens: 0, output_tokens: 0, calls_with_usage: 0 };
501
+ p.by_model.set(mKey, mAgg);
502
+ }
503
+ }
504
+ mAgg.calls += 1;
505
+ if (opts.error)
506
+ mAgg.errors += 1;
507
+ if (hasUsage)
508
+ mAgg.calls_with_usage += 1;
509
+ if (opts.inputTokens > 0)
510
+ mAgg.input_tokens += opts.inputTokens;
511
+ if (opts.outputTokens > 0)
512
+ mAgg.output_tokens += opts.outputTokens;
513
+ }
514
+ record(opts) {
515
+ if (this.isExcluded(opts.host))
516
+ return;
517
+ const provider = providerForHost(opts.host);
518
+ const durationMs = opts.durationMs ?? 0;
519
+ const error = Boolean(opts.error);
520
+ const inputTokens = opts.inputTokens ?? 0;
521
+ const outputTokens = opts.outputTokens ?? 0;
522
+ const model = opts.model ?? null;
523
+ // When callers pass serviceUnit / tenantId (including null), keep that
524
+ // call-entry snapshot — do not fall through to live ALS after await.
525
+ const serviceUnit = Object.prototype.hasOwnProperty.call(opts, 'serviceUnit')
526
+ ? (opts.serviceUnit || '').trim() || '__unknown__'
527
+ : attributionServiceUnit() || '__unknown__';
528
+ const tenantId = Object.prototype.hasOwnProperty.call(opts, 'tenantId')
529
+ ? (opts.tenantId || '').trim() || null
530
+ : getTenantContext()?.tenantId || null;
531
+ this.recordProvider(this.providers, {
532
+ provider,
533
+ host: opts.host,
534
+ durationMs,
535
+ error,
536
+ inputTokens,
537
+ outputTokens,
538
+ model,
539
+ });
540
+ let unitBucket = this.byServiceUnit.get(serviceUnit);
541
+ if (!unitBucket) {
542
+ unitBucket = new Map();
543
+ this.byServiceUnit.set(serviceUnit, unitBucket);
544
+ }
545
+ this.recordProvider(unitBucket, {
546
+ provider,
547
+ host: opts.host,
548
+ durationMs,
549
+ error,
550
+ inputTokens,
551
+ outputTokens,
552
+ model,
553
+ });
554
+ if (tenantId) {
555
+ let tenantBucket = this.byTenant.get(tenantId);
556
+ if (!tenantBucket) {
557
+ tenantBucket = new Map();
558
+ this.byTenant.set(tenantId, tenantBucket);
559
+ }
560
+ this.recordProvider(tenantBucket, {
561
+ provider,
562
+ host: opts.host,
563
+ durationMs,
564
+ error,
565
+ inputTokens,
566
+ outputTokens,
567
+ model,
568
+ });
569
+ }
570
+ }
571
+ providerRow(p) {
572
+ const row = {
573
+ provider_key: p.provider_key,
574
+ calls: p.calls,
575
+ errors: p.errors,
576
+ calls_with_usage: p.calls_with_usage,
577
+ };
578
+ if (p.calls > 0) {
579
+ row.duration_ms_avg = Math.round((p.duration_ms_total / p.calls) * 1000) / 1000;
580
+ }
581
+ if (p.hosts.size)
582
+ row.hosts = [...p.hosts].sort().slice(0, 8);
583
+ if (p.input_tokens)
584
+ row.input_tokens = p.input_tokens;
585
+ if (p.output_tokens)
586
+ row.output_tokens = p.output_tokens;
587
+ if (p.input_tokens || p.output_tokens) {
588
+ row.total_tokens = p.input_tokens + p.output_tokens;
589
+ }
590
+ if (p.models.size)
591
+ row.models = [...p.models].sort().slice(0, MAX_MODELS_PER_PROVIDER);
592
+ if (p.by_model.size) {
593
+ const byModel = [...p.by_model.values()]
594
+ .filter((m) => m.calls > 0)
595
+ .sort((a, b) => b.calls - a.calls)
596
+ .map((m) => {
597
+ const mRow = {
598
+ model: m.model,
599
+ calls: m.calls,
600
+ errors: m.errors,
601
+ calls_with_usage: m.calls_with_usage,
602
+ };
603
+ if (m.input_tokens)
604
+ mRow.input_tokens = m.input_tokens;
605
+ if (m.output_tokens)
606
+ mRow.output_tokens = m.output_tokens;
607
+ if (m.input_tokens || m.output_tokens) {
608
+ mRow.total_tokens = m.input_tokens + m.output_tokens;
609
+ }
610
+ return mRow;
611
+ });
612
+ if (byModel.length)
613
+ row.by_model = byModel;
614
+ }
615
+ return row;
616
+ }
617
+ buildV0() {
618
+ const providers = [...this.providers.values()].filter((p) => p.calls > 0);
619
+ if (!providers.length)
620
+ return null;
621
+ const out = providers
622
+ .sort((a, b) => b.calls - a.calls)
623
+ .map((p) => this.providerRow(p));
624
+ const by_service_unit = {};
625
+ for (const [unit, bucket] of this.byServiceUnit.entries()) {
626
+ const provs = [...bucket.values()].filter((p) => p.calls > 0);
627
+ if (!provs.length)
628
+ continue;
629
+ const rows = provs.sort((a, b) => b.calls - a.calls).map((p) => this.providerRow(p));
630
+ by_service_unit[unit] = {
631
+ service_unit: unit,
632
+ calls: rows.reduce((s, r) => s + r.calls, 0),
633
+ errors: rows.reduce((s, r) => s + r.errors, 0),
634
+ providers: rows,
635
+ };
636
+ }
637
+ const by_tenant = {};
638
+ for (const [tenant, bucket] of this.byTenant.entries()) {
639
+ const provs = [...bucket.values()].filter((p) => p.calls > 0);
640
+ if (!provs.length)
641
+ continue;
642
+ const rows = provs.sort((a, b) => b.calls - a.calls).map((p) => this.providerRow(p));
643
+ by_tenant[tenant] = {
644
+ tenant_id: tenant,
645
+ calls: rows.reduce((s, r) => s + r.calls, 0),
646
+ errors: rows.reduce((s, r) => s + r.errors, 0),
647
+ providers: rows,
648
+ };
649
+ }
650
+ const payload = {
651
+ collector: 'outbound_http',
652
+ providers: out,
653
+ };
654
+ if (Object.keys(by_service_unit).length)
655
+ payload.by_service_unit = by_service_unit;
656
+ if (Object.keys(by_tenant).length)
657
+ payload.by_tenant = by_tenant;
658
+ return payload;
659
+ }
660
+ takeSnapshot() {
661
+ const snap = new ExternalApiAggregator();
662
+ snap.providers = this.providers;
663
+ snap.byServiceUnit = this.byServiceUnit;
664
+ snap.byTenant = this.byTenant;
665
+ snap.excludedHosts = new Set(this.excludedHosts);
666
+ this.providers = new Map();
667
+ this.byServiceUnit = new Map();
668
+ this.byTenant = new Map();
669
+ return snap;
670
+ }
671
+ static mergeProvider(target, source) {
672
+ target.calls += source.calls;
673
+ target.errors += source.errors;
674
+ target.duration_ms_total += source.duration_ms_total;
675
+ target.input_tokens += source.input_tokens;
676
+ target.output_tokens += source.output_tokens;
677
+ target.calls_with_usage += source.calls_with_usage;
678
+ for (const h of source.hosts) {
679
+ if (target.hosts.size >= 8)
680
+ break;
681
+ target.hosts.add(h);
682
+ }
683
+ for (const m of source.models) {
684
+ if (target.models.size >= MAX_MODELS_PER_PROVIDER)
685
+ break;
686
+ target.models.add(m);
687
+ }
688
+ for (const [key, src] of source.by_model.entries()) {
689
+ let dst = target.by_model.get(key);
690
+ if (!dst) {
691
+ if (target.by_model.size >= MAX_MODELS_PER_PROVIDER &&
692
+ !target.by_model.has(key)) {
693
+ const otherKey = '__other__';
694
+ dst = target.by_model.get(otherKey);
695
+ if (!dst) {
696
+ dst = { model: otherKey, calls: 0, errors: 0, input_tokens: 0, output_tokens: 0, calls_with_usage: 0 };
697
+ target.by_model.set(otherKey, dst);
698
+ }
699
+ }
700
+ else {
701
+ target.by_model.set(key, { ...src });
702
+ continue;
703
+ }
704
+ }
705
+ dst.calls += src.calls;
706
+ dst.errors += src.errors;
707
+ dst.input_tokens += src.input_tokens;
708
+ dst.output_tokens += src.output_tokens;
709
+ dst.calls_with_usage += src.calls_with_usage;
710
+ }
711
+ }
712
+ mergeSnapshot(snapshot) {
713
+ for (const [key, src] of snapshot.providers.entries()) {
714
+ const dst = this.providers.get(key);
715
+ if (!dst)
716
+ this.providers.set(key, src);
717
+ else
718
+ ExternalApiAggregator.mergeProvider(dst, src);
719
+ }
720
+ for (const [unit, srcBucket] of snapshot.byServiceUnit.entries()) {
721
+ let dstBucket = this.byServiceUnit.get(unit);
722
+ if (!dstBucket) {
723
+ this.byServiceUnit.set(unit, srcBucket);
724
+ continue;
725
+ }
726
+ for (const [key, src] of srcBucket.entries()) {
727
+ const dst = dstBucket.get(key);
728
+ if (!dst)
729
+ dstBucket.set(key, src);
730
+ else
731
+ ExternalApiAggregator.mergeProvider(dst, src);
732
+ }
733
+ }
734
+ for (const [tenant, srcBucket] of snapshot.byTenant.entries()) {
735
+ let dstBucket = this.byTenant.get(tenant);
736
+ if (!dstBucket) {
737
+ this.byTenant.set(tenant, srcBucket);
738
+ continue;
739
+ }
740
+ for (const [key, src] of srcBucket.entries()) {
741
+ const dst = dstBucket.get(key);
742
+ if (!dst)
743
+ dstBucket.set(key, src);
744
+ else
745
+ ExternalApiAggregator.mergeProvider(dst, src);
746
+ }
747
+ }
748
+ snapshot.providers = new Map();
749
+ snapshot.byServiceUnit = new Map();
750
+ snapshot.byTenant = new Map();
751
+ }
752
+ clear() {
753
+ this.providers.clear();
754
+ this.byServiceUnit.clear();
755
+ this.byTenant.clear();
756
+ }
757
+ }
758
+ const globalAgg = new ExternalApiAggregator();
759
+ let installed = false;
760
+ let originalFetch = null;
761
+ let restoreAxios = null;
762
+ let restoreUndici = null;
763
+ const pendingUsageParses = [];
764
+ function recordMeteredCall(opts) {
765
+ try {
766
+ globalAgg.record({
767
+ host: opts.host,
768
+ durationMs: opts.durationMs,
769
+ error: opts.error,
770
+ inputTokens: opts.usage.input,
771
+ outputTokens: opts.usage.output,
772
+ model: opts.usage.model || modelFromUrl(opts.url),
773
+ serviceUnit: opts.serviceUnit,
774
+ tenantId: opts.tenantId,
775
+ });
776
+ }
777
+ catch {
778
+ /* never break caller */
779
+ }
780
+ }
781
+ function resolveAxiosUrl(config) {
782
+ if (!config || typeof config !== 'object')
783
+ return '';
784
+ const base = typeof config.baseURL === 'string' ? config.baseURL : '';
785
+ const path = typeof config.url === 'string' ? config.url : '';
786
+ if (!base && !path)
787
+ return '';
788
+ try {
789
+ if (base)
790
+ return new URL(path || '', base).toString();
791
+ return new URL(path).toString();
792
+ }
793
+ catch {
794
+ return path || base;
795
+ }
796
+ }
797
+ function headerContentType(headers) {
798
+ if (!headers || typeof headers !== 'object')
799
+ return null;
800
+ const h = headers;
801
+ for (const key of Object.keys(h)) {
802
+ if (key.toLowerCase() === 'content-type') {
803
+ const v = h[key];
804
+ if (typeof v === 'string')
805
+ return v;
806
+ if (Array.isArray(v) && typeof v[0] === 'string')
807
+ return v[0];
808
+ }
809
+ }
810
+ // axios Headers object
811
+ const get = headers.get;
812
+ if (typeof get === 'function') {
813
+ const v = get.call(headers, 'content-type');
814
+ if (typeof v === 'string')
815
+ return v;
816
+ }
817
+ return null;
818
+ }
819
+ export async function _awaitPendingUsageParses() {
820
+ await Promise.allSettled(pendingUsageParses.splice(0, pendingUsageParses.length));
821
+ }
822
+ export function getExternalAggregator() {
823
+ return globalAgg;
824
+ }
825
+ export function getOriginalFetch() {
826
+ return originalFetch;
827
+ }
828
+ export function buildExternalV0() {
829
+ return globalAgg.buildV0();
830
+ }
831
+ export function clearExternalAggregator() {
832
+ globalAgg.clear();
833
+ }
834
+ function installFetchWrap() {
835
+ if (typeof globalThis.fetch !== 'function')
836
+ return;
837
+ if (globalThis.fetch._netgreenerWrapped)
838
+ return;
839
+ originalFetch = globalThis.fetch.bind(globalThis);
840
+ const base = originalFetch;
841
+ const wrapped = async (input, init) => {
842
+ // Axios (or another outer wrap) already owns this call.
843
+ if (outboundMeterOwnerAls.getStore()) {
844
+ return base(input, init);
845
+ }
846
+ const url = typeof input === 'string'
847
+ ? input
848
+ : input instanceof URL
849
+ ? input.toString()
850
+ : input.url;
851
+ const host = hostFromUrl(url);
852
+ // Snapshot inbound attribution when the outbound call starts — not after await.
853
+ const serviceUnitAtStart = attributionServiceUnit();
854
+ const tenantIdAtStart = getTenantContext()?.tenantId ?? null;
855
+ const start = performance.now();
856
+ let error = false;
857
+ let response;
858
+ try {
859
+ response = await outboundMeterOwnerAls.run('fetch', () => base(input, init));
860
+ if (response.status >= 400)
861
+ error = true;
862
+ return response;
863
+ }
864
+ catch (err) {
865
+ error = true;
866
+ throw err;
867
+ }
868
+ finally {
869
+ const durationMs = performance.now() - start;
870
+ const recordUsage = (usage) => {
871
+ recordMeteredCall({
872
+ host,
873
+ url,
874
+ durationMs,
875
+ error,
876
+ usage,
877
+ serviceUnit: serviceUnitAtStart,
878
+ tenantId: tenantIdAtStart,
879
+ });
880
+ };
881
+ if (!response) {
882
+ recordUsage({ input: 0, output: 0, model: modelFromUrl(url) });
883
+ }
884
+ else {
885
+ const captured = response;
886
+ const parse = async () => {
887
+ try {
888
+ return await extractUsageFromResponse(captured, url);
889
+ }
890
+ catch {
891
+ return { input: 0, output: 0, model: modelFromUrl(url) };
892
+ }
893
+ };
894
+ if (isStreamingContentType(captured)) {
895
+ pendingUsageParses.push(parse().then(recordUsage));
896
+ }
897
+ else {
898
+ try {
899
+ recordUsage(await parse());
900
+ }
901
+ catch {
902
+ recordUsage({ input: 0, output: 0, model: modelFromUrl(url) });
903
+ }
904
+ }
905
+ }
906
+ }
907
+ };
908
+ wrapped._netgreenerWrapped = true;
909
+ globalThis.fetch = wrapped;
910
+ }
911
+ function installAxiosWrap() {
912
+ let axiosMod;
913
+ try {
914
+ axiosMod = requireFromHere('axios');
915
+ }
916
+ catch {
917
+ return;
918
+ }
919
+ const AxiosCtor = axiosMod.Axios || axiosMod.default?.Axios;
920
+ if (!AxiosCtor?.prototype?.request)
921
+ return;
922
+ const proto = AxiosCtor.prototype;
923
+ if (proto.request._netgreenerWrapped)
924
+ return;
925
+ const originalRequest = proto.request;
926
+ const wrappedRequest = function netgreenerAxiosRequest(...args) {
927
+ if (outboundMeterOwnerAls.getStore()) {
928
+ return originalRequest.apply(this, args);
929
+ }
930
+ const configOrUrl = args[0];
931
+ const config = args[1];
932
+ const cfg = typeof configOrUrl === 'string'
933
+ ? { ...(typeof config === 'object' && config ? config : {}), url: configOrUrl }
934
+ : configOrUrl && typeof configOrUrl === 'object'
935
+ ? { ...configOrUrl }
936
+ : {};
937
+ const url = resolveAxiosUrl(cfg);
938
+ const host = hostFromUrl(url);
939
+ const serviceUnitAtStart = attributionServiceUnit();
940
+ const tenantIdAtStart = getTenantContext()?.tenantId ?? null;
941
+ const start = performance.now();
942
+ let error = false;
943
+ const invoke = () => originalRequest.apply(this, args);
944
+ const after = (result, threw) => {
945
+ const durationMs = performance.now() - start;
946
+ if (threw) {
947
+ recordMeteredCall({
948
+ host,
949
+ url,
950
+ durationMs,
951
+ error: true,
952
+ usage: { input: 0, output: 0, model: modelFromUrl(url) },
953
+ serviceUnit: serviceUnitAtStart,
954
+ tenantId: tenantIdAtStart,
955
+ });
956
+ return result;
957
+ }
958
+ const resp = result;
959
+ try {
960
+ if (resp && typeof resp.status === 'number' && resp.status >= 400)
961
+ error = true;
962
+ }
963
+ catch {
964
+ /* ignore */
965
+ }
966
+ const responseType = String(resp?.config?.responseType || cfg.responseType || '').toLowerCase();
967
+ const streaming = responseType === 'stream';
968
+ let usage = { input: 0, output: 0, model: modelFromUrl(url) };
969
+ if (!streaming && resp) {
970
+ try {
971
+ usage = usageFromClientBody(resp.data, url, headerContentType(resp.headers));
972
+ }
973
+ catch {
974
+ /* zeros */
975
+ }
976
+ }
977
+ recordMeteredCall({
978
+ host,
979
+ url,
980
+ durationMs,
981
+ error,
982
+ usage,
983
+ serviceUnit: serviceUnitAtStart,
984
+ tenantId: tenantIdAtStart,
985
+ });
986
+ return result;
987
+ };
988
+ try {
989
+ const maybePromise = outboundMeterOwnerAls.run('axios', invoke);
990
+ if (maybePromise && typeof maybePromise.then === 'function') {
991
+ return maybePromise.then((r) => after(r, false), (err) => {
992
+ after(undefined, true);
993
+ throw err;
994
+ });
995
+ }
996
+ return after(maybePromise, false);
997
+ }
998
+ catch (err) {
999
+ after(undefined, true);
1000
+ throw err;
1001
+ }
1002
+ };
1003
+ wrappedRequest._netgreenerWrapped = true;
1004
+ proto.request = wrappedRequest;
1005
+ restoreAxios = () => {
1006
+ proto.request = originalRequest;
1007
+ restoreAxios = null;
1008
+ };
1009
+ }
1010
+ function installUndiciWrap() {
1011
+ let undici;
1012
+ try {
1013
+ undici = requireFromHere('undici');
1014
+ }
1015
+ catch {
1016
+ return;
1017
+ }
1018
+ const wrapUndiciRequest = (original) => {
1019
+ const wrapped = function netgreenerUndiciRequest(...args) {
1020
+ // Outer fetch/axios already recorded — undici is the transport only.
1021
+ if (outboundMeterOwnerAls.getStore()) {
1022
+ return original.apply(this, args);
1023
+ }
1024
+ // Dispatcher.prototype.request(opts) vs undici.request(url, opts)
1025
+ let url = '';
1026
+ if (typeof args[0] === 'string' || args[0] instanceof URL) {
1027
+ url = String(args[0]);
1028
+ }
1029
+ else if (args[0] && typeof args[0] === 'object') {
1030
+ const opts = args[0];
1031
+ if (typeof opts.path === 'string' && (opts.origin || opts.hostname)) {
1032
+ url = `${opts.origin || `https://${opts.hostname}`}${opts.path}`;
1033
+ }
1034
+ else if (typeof opts.url === 'string') {
1035
+ url = String(opts.url);
1036
+ }
1037
+ }
1038
+ const host = hostFromUrl(url);
1039
+ const serviceUnitAtStart = attributionServiceUnit();
1040
+ const tenantIdAtStart = getTenantContext()?.tenantId ?? null;
1041
+ const start = performance.now();
1042
+ const finish = (result, threw) => {
1043
+ const durationMs = performance.now() - start;
1044
+ let error = threw;
1045
+ if (!threw && result && typeof result === 'object') {
1046
+ const status = result.statusCode;
1047
+ if (typeof status === 'number' && status >= 400)
1048
+ error = true;
1049
+ }
1050
+ // Do not consume undici body streams — call/error/duration only (tokens via fetch/axios JSON).
1051
+ recordMeteredCall({
1052
+ host,
1053
+ url,
1054
+ durationMs,
1055
+ error,
1056
+ usage: { input: 0, output: 0, model: modelFromUrl(url) },
1057
+ serviceUnit: serviceUnitAtStart,
1058
+ tenantId: tenantIdAtStart,
1059
+ });
1060
+ return result;
1061
+ };
1062
+ try {
1063
+ const maybePromise = outboundMeterOwnerAls.run('undici', () => original.apply(this, args));
1064
+ if (maybePromise && typeof maybePromise.then === 'function') {
1065
+ return maybePromise.then((r) => finish(r, false), (err) => {
1066
+ finish(undefined, true);
1067
+ throw err;
1068
+ });
1069
+ }
1070
+ return finish(maybePromise, false);
1071
+ }
1072
+ catch (err) {
1073
+ finish(undefined, true);
1074
+ throw err;
1075
+ }
1076
+ };
1077
+ wrapped._netgreenerWrapped = true;
1078
+ return wrapped;
1079
+ };
1080
+ const restores = [];
1081
+ // Wrap both the module helper and Dispatcher.prototype. Nested calls are
1082
+ // de-duped by outboundMeterOwnerAls (undici.request → dispatcher.request).
1083
+ if (typeof undici.request === 'function' && !undici.request._netgreenerWrapped) {
1084
+ const original = undici.request.bind(undici);
1085
+ undici.request = wrapUndiciRequest(original);
1086
+ restores.push(() => {
1087
+ undici.request = original;
1088
+ });
1089
+ }
1090
+ const dispProto = undici.Dispatcher?.prototype;
1091
+ if (dispProto && typeof dispProto.request === 'function' && !dispProto.request._netgreenerWrapped) {
1092
+ const original = dispProto.request;
1093
+ dispProto.request = wrapUndiciRequest(function (...args) {
1094
+ return original.apply(this, args);
1095
+ });
1096
+ restores.push(() => {
1097
+ dispProto.request = original;
1098
+ });
1099
+ }
1100
+ if (restores.length) {
1101
+ restoreUndici = () => {
1102
+ for (const r of restores) {
1103
+ try {
1104
+ r();
1105
+ }
1106
+ catch {
1107
+ /* ignore */
1108
+ }
1109
+ }
1110
+ restoreUndici = null;
1111
+ };
1112
+ }
1113
+ }
1114
+ /** Test helper — reset install state. */
1115
+ export function _resetOutboundInstrumentationForTests() {
1116
+ pendingUsageParses.length = 0;
1117
+ if (originalFetch && typeof globalThis.fetch === 'function') {
1118
+ globalThis.fetch = originalFetch;
1119
+ }
1120
+ originalFetch = null;
1121
+ if (restoreAxios) {
1122
+ try {
1123
+ restoreAxios();
1124
+ }
1125
+ catch {
1126
+ /* ignore */
1127
+ }
1128
+ }
1129
+ restoreAxios = null;
1130
+ if (restoreUndici) {
1131
+ try {
1132
+ restoreUndici();
1133
+ }
1134
+ catch {
1135
+ /* ignore */
1136
+ }
1137
+ }
1138
+ restoreUndici = null;
1139
+ installed = false;
1140
+ globalAgg.clear();
1141
+ }
1142
+ export function installOutboundInstrumentation(opts) {
1143
+ if (installed)
1144
+ return;
1145
+ installed = true;
1146
+ if (opts?.excludedHosts) {
1147
+ globalAgg.setExcludedHosts(opts.excludedHosts);
1148
+ }
1149
+ try {
1150
+ installFetchWrap();
1151
+ }
1152
+ catch {
1153
+ /* never break caller bootstrap */
1154
+ }
1155
+ try {
1156
+ installAxiosWrap();
1157
+ }
1158
+ catch {
1159
+ /* optional peer */
1160
+ }
1161
+ try {
1162
+ installUndiciWrap();
1163
+ }
1164
+ catch {
1165
+ /* optional / built-in */
1166
+ }
1167
+ }