@plusscommunities/pluss-llm-telemetry-aws 1.0.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 (3) hide show
  1. package/README.md +32 -0
  2. package/index.js +507 -0
  3. package/package.json +27 -0
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # `@plusscommunities/pluss-llm-telemetry-aws`
2
+
3
+ Dependency-free CommonJS telemetry for Pluss Communities LLM invocations. The package exports:
4
+
5
+ - `withLlmTelemetry(options)`, which records one JSON-safe `llm.invocation` event while preserving the provider result or thrown-error identity.
6
+ - `GLOBAL_COMMUNITY_ID`, the attribution value for model calls that are not scoped to one community.
7
+
8
+ The event includes the complete normalized prompt and completion, model and operation identifiers, token usage, latency, pricing evidence, feature/community attribution, trace correlation, and projected provider diagnostics. `usageIncomplete` is `1` when either input or output usage is missing. `telemetryError` distinguishes a success-extraction failure from legitimately absent provider usage. Callers must provide deployment-pinned USD token pricing and may inject extraction, logging, clock, invocation-ID, and `onTelemetryError` functions. Logger failures never replace the provider outcome; without an injected reporter they produce a minimal JSON-safe `llm.telemetry_error` line on stderr.
9
+
10
+ ## Import boundary
11
+
12
+ Backend extensions must not import this package directly. They must use the stable core facade:
13
+
14
+ ```js
15
+ const {
16
+ GLOBAL_COMMUNITY_ID,
17
+ withLlmTelemetry,
18
+ } = require("@plusscommunities/pluss-core-aws/helper/llmTelemetry");
19
+ ```
20
+
21
+ Parent backend services that do not need the legacy core package may depend on this zero-runtime-dependency package directly.
22
+
23
+ ## Release order
24
+
25
+ Release consumers only in this order:
26
+
27
+ 1. Publish `@plusscommunities/pluss-llm-telemetry-aws@1.0.0`.
28
+ 2. Publish `@plusscommunities/pluss-core-aws@2.0.30`, whose facade depends on telemetry `1.0.0`.
29
+ 3. Publish every newsletter package variant at `2.0.12`, then update and release the parent `minuss-aws` support-assistant integration.
30
+ 4. After committing these extension-repository changes, update the `pluss-extension-aws` gitlink in the real parent `minuss-aws` repository to that extension commit.
31
+
32
+ Do not publish core or install it in a registry-exact consumer before telemetry `1.0.0` is available.
package/index.js ADDED
@@ -0,0 +1,507 @@
1
+ const { randomUUID } = require("node:crypto");
2
+
3
+ const GLOBAL_COMMUNITY_ID = "urn:pluss:community:global";
4
+ const MAX_JSON_DEPTH = 100;
5
+ const MAX_ERROR_DEPTH = 12;
6
+ const ERROR_SNAPSHOT_FIELDS = [
7
+ "name",
8
+ "message",
9
+ "stack",
10
+ "code",
11
+ "statusCode",
12
+ "httpStatusCode",
13
+ "requestId",
14
+ "retryable",
15
+ "$fault",
16
+ "$retryable",
17
+ "$metadata",
18
+ "originalStatusCode",
19
+ "resourceName",
20
+ "originalMessage",
21
+ "details",
22
+ "url",
23
+ "requestBodyValues",
24
+ "responseHeaders",
25
+ "responseBody",
26
+ "isRetryable",
27
+ "data",
28
+ "cause",
29
+ "errors",
30
+ "reason",
31
+ "lastError",
32
+ ];
33
+ const ERROR_CHAIN_FIELDS = new Set(["cause", "errors", "lastError"]);
34
+
35
+ const defaultCreateInvocationId = () => `llm_${randomUUID()}`;
36
+
37
+ const defaultLogger = (event) =>
38
+ process.stdout.write(`${JSON.stringify(event)}\n`);
39
+
40
+ const defaultTelemetryErrorReporter = (diagnostic) =>
41
+ process.stderr.write(`${JSON.stringify(diagnostic)}\n`);
42
+
43
+ const readProperty = (value, key) => {
44
+ try {
45
+ return value?.[key];
46
+ } catch (_error) {
47
+ return undefined;
48
+ }
49
+ };
50
+
51
+ const toJsonSafe = (
52
+ value,
53
+ ancestors = new WeakSet(),
54
+ depth = 0,
55
+ includeErrorFields = false,
56
+ errorDepth = 0,
57
+ ) => {
58
+ if (value === null) return null;
59
+ if (["string", "boolean"].includes(typeof value)) return value;
60
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
61
+ if (typeof value === "bigint") return value.toString();
62
+ if (["undefined", "function", "symbol"].includes(typeof value)) {
63
+ return undefined;
64
+ }
65
+ if (typeof value !== "object") return String(value);
66
+ if (ancestors.has(value)) return "[Circular]";
67
+ if (
68
+ depth >= MAX_JSON_DEPTH ||
69
+ (includeErrorFields && errorDepth >= MAX_ERROR_DEPTH)
70
+ ) {
71
+ return "[MaxDepth]";
72
+ }
73
+
74
+ ancestors.add(value);
75
+ try {
76
+ if (Array.isArray(value)) {
77
+ return value.map((item) => {
78
+ const safeItem = toJsonSafe(
79
+ item,
80
+ ancestors,
81
+ depth + 1,
82
+ includeErrorFields,
83
+ errorDepth,
84
+ );
85
+ return safeItem === undefined ? null : safeItem;
86
+ });
87
+ }
88
+
89
+ const safeValue = {};
90
+ let keys;
91
+ try {
92
+ keys = Object.keys(value);
93
+ } catch (_error) {
94
+ return "[Unserializable]";
95
+ }
96
+ if (includeErrorFields) {
97
+ for (const key of ERROR_SNAPSHOT_FIELDS) {
98
+ if (!keys.includes(key) && readProperty(value, key) !== undefined) {
99
+ keys.push(key);
100
+ }
101
+ }
102
+ }
103
+ for (const key of keys) {
104
+ const isErrorChain = includeErrorFields && ERROR_CHAIN_FIELDS.has(key);
105
+ const safeProperty = toJsonSafe(
106
+ readProperty(value, key),
107
+ ancestors,
108
+ depth + 1,
109
+ isErrorChain,
110
+ isErrorChain ? errorDepth + 1 : errorDepth,
111
+ );
112
+ if (safeProperty !== undefined) {
113
+ Object.defineProperty(safeValue, key, {
114
+ configurable: true,
115
+ enumerable: true,
116
+ value: safeProperty,
117
+ writable: true,
118
+ });
119
+ }
120
+ }
121
+ return safeValue;
122
+ } finally {
123
+ ancestors.delete(value);
124
+ }
125
+ };
126
+
127
+ const defaultExtractError = (error) => {
128
+ const metadata = readProperty(error, "$metadata");
129
+ const directHttpStatusCode = readProperty(error, "httpStatusCode");
130
+ const directRequestId = readProperty(error, "requestId");
131
+ const directRetryable = readProperty(error, "retryable");
132
+ const projected = {
133
+ name: readProperty(error, "name"),
134
+ message: readProperty(error, "message"),
135
+ stack: readProperty(error, "stack"),
136
+ code: readProperty(error, "code"),
137
+ statusCode: readProperty(error, "statusCode"),
138
+ httpStatusCode:
139
+ directHttpStatusCode === undefined
140
+ ? readProperty(metadata, "httpStatusCode")
141
+ : directHttpStatusCode,
142
+ requestId:
143
+ directRequestId === undefined
144
+ ? readProperty(metadata, "requestId")
145
+ : directRequestId,
146
+ retryable:
147
+ directRetryable === undefined
148
+ ? readProperty(error, "$retryable")
149
+ : directRetryable,
150
+ $fault: readProperty(error, "$fault"),
151
+ $retryable: readProperty(error, "$retryable"),
152
+ $metadata: metadata,
153
+ originalStatusCode: readProperty(error, "originalStatusCode"),
154
+ resourceName: readProperty(error, "resourceName"),
155
+ originalMessage: readProperty(error, "originalMessage"),
156
+ details: readProperty(error, "details"),
157
+ url: readProperty(error, "url"),
158
+ requestBodyValues: readProperty(error, "requestBodyValues"),
159
+ responseHeaders: readProperty(error, "responseHeaders"),
160
+ responseBody: readProperty(error, "responseBody"),
161
+ isRetryable: readProperty(error, "isRetryable"),
162
+ data: readProperty(error, "data"),
163
+ cause: readProperty(error, "cause"),
164
+ errors: readProperty(error, "errors"),
165
+ reason: readProperty(error, "reason"),
166
+ lastError: readProperty(error, "lastError"),
167
+ };
168
+ return projected;
169
+ };
170
+
171
+ const validatePricing = (pricing) => {
172
+ if (!pricing || pricing.currency !== "USD") {
173
+ throw new TypeError("LLM pricing currency must be USD.");
174
+ }
175
+ if (
176
+ !Number.isFinite(pricing.inputUsdPerMillionTokens) ||
177
+ pricing.inputUsdPerMillionTokens < 0
178
+ ) {
179
+ throw new TypeError(
180
+ "LLM input token pricing must be a finite non-negative number.",
181
+ );
182
+ }
183
+ if (
184
+ !Number.isFinite(pricing.outputUsdPerMillionTokens) ||
185
+ pricing.outputUsdPerMillionTokens < 0
186
+ ) {
187
+ throw new TypeError(
188
+ "LLM output token pricing must be a finite non-negative number.",
189
+ );
190
+ }
191
+ if (typeof pricing.source !== "string" || !pricing.source.trim()) {
192
+ throw new TypeError("LLM pricing source must be a non-empty string.");
193
+ }
194
+ if (typeof pricing.version !== "string" || !pricing.version.trim()) {
195
+ throw new TypeError("LLM pricing version must be a non-empty string.");
196
+ }
197
+ };
198
+
199
+ const normalizeTokenCount = (value) =>
200
+ Number.isSafeInteger(value) && value >= 0 ? value : null;
201
+
202
+ const nullSuccessExtraction = (telemetryError) => ({
203
+ completion: null,
204
+ inputTokens: null,
205
+ outputTokens: null,
206
+ totalTokens: null,
207
+ stopReason: null,
208
+ telemetryError,
209
+ });
210
+
211
+ const isThenable = (value) => {
212
+ if (value === null || !["object", "function"].includes(typeof value)) {
213
+ return false;
214
+ }
215
+ try {
216
+ return typeof value.then === "function";
217
+ } catch (_error) {
218
+ return true;
219
+ }
220
+ };
221
+
222
+ const observeRejectedThenable = (value, onRejected) => {
223
+ try {
224
+ const promise = Promise.resolve(value);
225
+ Promise.prototype.then.call(promise, undefined, (error) => {
226
+ try {
227
+ onRejected(error);
228
+ } catch (_reportingError) {
229
+ // Detached diagnostics must not create another failure path.
230
+ }
231
+ });
232
+ } catch (error) {
233
+ try {
234
+ onRejected(error);
235
+ } catch (_reportingError) {
236
+ // Detached diagnostics must not create another failure path.
237
+ }
238
+ }
239
+ };
240
+
241
+ const suppressRejectedThenable = (value) => {
242
+ if (!isThenable(value)) return false;
243
+ observeRejectedThenable(value, () => {});
244
+ return true;
245
+ };
246
+
247
+ const createErrorSnapshot = (error) => {
248
+ const snapshot = toJsonSafe(error, new WeakSet(), 0, true);
249
+ if (
250
+ snapshot === null ||
251
+ typeof snapshot !== "object" ||
252
+ Array.isArray(snapshot)
253
+ ) {
254
+ return snapshot;
255
+ }
256
+
257
+ for (const key of ERROR_SNAPSHOT_FIELDS) {
258
+ if (Object.hasOwn(snapshot, key)) continue;
259
+ const safeProperty = toJsonSafe(readProperty(error, key));
260
+ if (safeProperty !== undefined) {
261
+ Object.defineProperty(snapshot, key, {
262
+ configurable: true,
263
+ enumerable: true,
264
+ value: safeProperty,
265
+ writable: true,
266
+ });
267
+ }
268
+ }
269
+ return snapshot;
270
+ };
271
+
272
+ const extractSuccessSafely = (extractSuccess, result, reportTelemetryError) => {
273
+ try {
274
+ const extracted = extractSuccess(toJsonSafe(result));
275
+ if (isThenable(extracted)) {
276
+ observeRejectedThenable(extracted, (error) =>
277
+ reportTelemetryError("extractSuccess", error),
278
+ );
279
+ const error = new TypeError(
280
+ "LLM success extractors must return synchronously, not a thenable.",
281
+ );
282
+ return nullSuccessExtraction({
283
+ phase: "extractSuccess",
284
+ error: createErrorSnapshot(error),
285
+ });
286
+ }
287
+ return {
288
+ completion: toJsonSafe(extracted?.completion ?? null),
289
+ inputTokens: normalizeTokenCount(extracted?.inputTokens),
290
+ outputTokens: normalizeTokenCount(extracted?.outputTokens),
291
+ totalTokens: normalizeTokenCount(extracted?.totalTokens),
292
+ stopReason: toJsonSafe(extracted?.stopReason ?? null),
293
+ telemetryError: null,
294
+ };
295
+ } catch (error) {
296
+ return nullSuccessExtraction({
297
+ phase: "extractSuccess",
298
+ error: createErrorSnapshot(error),
299
+ });
300
+ }
301
+ };
302
+
303
+ const extractErrorSafely = (extractError, error) => {
304
+ const errorSnapshot = createErrorSnapshot(error);
305
+ const fallback = toJsonSafe(defaultExtractError(errorSnapshot));
306
+ try {
307
+ const extracted = extractError(errorSnapshot);
308
+ if (suppressRejectedThenable(extracted)) return fallback;
309
+ if (extracted && typeof extracted === "object") {
310
+ return toJsonSafe(extracted);
311
+ }
312
+ } catch (_error) {
313
+ // Fall through to the default projection captured before extraction.
314
+ }
315
+ return fallback;
316
+ };
317
+
318
+ const readClock = (clock) => {
319
+ try {
320
+ const value = clock();
321
+ if (suppressRejectedThenable(value)) return null;
322
+ return Number.isFinite(value) ? value : null;
323
+ } catch (_error) {
324
+ return null;
325
+ }
326
+ };
327
+
328
+ const calculateLatency = (startedAt, endedAt) => {
329
+ if (startedAt === null || endedAt === null) return null;
330
+ const latencyMs = endedAt - startedAt;
331
+ return Number.isFinite(latencyMs) && latencyMs >= 0 ? latencyMs : null;
332
+ };
333
+
334
+ const createInvocationIdSafely = (createInvocationId) => {
335
+ try {
336
+ const invocationId = createInvocationId();
337
+ if (suppressRejectedThenable(invocationId)) {
338
+ return defaultCreateInvocationId();
339
+ }
340
+ if (typeof invocationId === "string" && invocationId) return invocationId;
341
+ } catch (_error) {
342
+ // Fall back to the production identifier generator.
343
+ }
344
+ return defaultCreateInvocationId();
345
+ };
346
+
347
+ const createTelemetryErrorDiagnostic = (context, phase, error) => ({
348
+ eventType: "llm.telemetry_error",
349
+ schemaVersion: 1,
350
+ invocationId: context.invocationId,
351
+ phase,
352
+ featureId: context.featureId,
353
+ modelId: context.modelId,
354
+ operation: context.operation,
355
+ region: context.region,
356
+ communityId: context.communityId,
357
+ traceId: context.traceId,
358
+ error: createErrorSnapshot(error),
359
+ });
360
+
361
+ const reportTelemetryErrorSafely = (reporter, diagnostic) => {
362
+ try {
363
+ const reporterResult = reporter(toJsonSafe(diagnostic));
364
+ suppressRejectedThenable(reporterResult);
365
+ } catch (_error) {
366
+ // Reporter failure is terminally suppressed to prevent recursion.
367
+ }
368
+ };
369
+
370
+ const emitSafely = (logger, eventFactory, reportTelemetryError) => {
371
+ try {
372
+ const loggerResult = logger(toJsonSafe(eventFactory()));
373
+ if (isThenable(loggerResult)) {
374
+ observeRejectedThenable(loggerResult, (error) =>
375
+ reportTelemetryError("logger", error),
376
+ );
377
+ }
378
+ } catch (error) {
379
+ reportTelemetryError("logger", error);
380
+ }
381
+ };
382
+
383
+ /**
384
+ * Runs one provider invocation and emits a detached structured telemetry event.
385
+ * @param {object} options Invocation and telemetry configuration.
386
+ * @returns {Promise<*>} The provider result without changing its identity.
387
+ */
388
+ const withLlmTelemetry = async ({
389
+ invoke,
390
+ operation,
391
+ modelId,
392
+ region = null,
393
+ prompt,
394
+ featureId,
395
+ communityId = GLOBAL_COMMUNITY_ID,
396
+ traceId,
397
+ pricing,
398
+ extractSuccess,
399
+ extractError = defaultExtractError,
400
+ logger = defaultLogger,
401
+ onTelemetryError = defaultTelemetryErrorReporter,
402
+ clock = Date.now,
403
+ createInvocationId = defaultCreateInvocationId,
404
+ }) => {
405
+ validatePricing(pricing);
406
+
407
+ const invocationId = createInvocationIdSafely(createInvocationId);
408
+ const resolvedTraceId = traceId === undefined ? invocationId : traceId;
409
+ const diagnosticContext = {
410
+ invocationId,
411
+ featureId,
412
+ modelId,
413
+ operation,
414
+ region,
415
+ communityId,
416
+ traceId: resolvedTraceId,
417
+ };
418
+ const reportTelemetryError = (phase, error) =>
419
+ reportTelemetryErrorSafely(
420
+ onTelemetryError,
421
+ createTelemetryErrorDiagnostic(diagnosticContext, phase, error),
422
+ );
423
+ const startedAt = readClock(clock);
424
+
425
+ try {
426
+ const result = await invoke();
427
+ const endedAt = readClock(clock);
428
+ emitSafely(
429
+ logger,
430
+ () => {
431
+ const success = extractSuccessSafely(
432
+ extractSuccess,
433
+ result,
434
+ reportTelemetryError,
435
+ );
436
+ return {
437
+ eventType: "llm.invocation",
438
+ schemaVersion: 1,
439
+ invocationId,
440
+ operation,
441
+ modelId,
442
+ region,
443
+ prompt,
444
+ completion: success.completion,
445
+ inputTokens: success.inputTokens,
446
+ outputTokens: success.outputTokens,
447
+ totalTokens: success.totalTokens,
448
+ latencyMs: calculateLatency(startedAt, endedAt),
449
+ stopReason: success.stopReason,
450
+ error: null,
451
+ telemetryError: success.telemetryError,
452
+ usageIncomplete:
453
+ success.inputTokens === null || success.outputTokens === null
454
+ ? 1
455
+ : 0,
456
+ featureId,
457
+ communityId,
458
+ traceId: resolvedTraceId,
459
+ outcome: "success",
460
+ pricingCurrency: pricing.currency,
461
+ inputUsdPerMillionTokens: pricing.inputUsdPerMillionTokens,
462
+ outputUsdPerMillionTokens: pricing.outputUsdPerMillionTokens,
463
+ pricingSource: pricing.source,
464
+ pricingVersion: pricing.version,
465
+ };
466
+ },
467
+ reportTelemetryError,
468
+ );
469
+ return result;
470
+ } catch (error) {
471
+ const endedAt = readClock(clock);
472
+ emitSafely(
473
+ logger,
474
+ () => ({
475
+ eventType: "llm.invocation",
476
+ schemaVersion: 1,
477
+ invocationId,
478
+ operation,
479
+ modelId,
480
+ region,
481
+ prompt,
482
+ completion: null,
483
+ inputTokens: null,
484
+ outputTokens: null,
485
+ totalTokens: null,
486
+ latencyMs: calculateLatency(startedAt, endedAt),
487
+ stopReason: null,
488
+ error: extractErrorSafely(extractError, error),
489
+ telemetryError: null,
490
+ usageIncomplete: 1,
491
+ featureId,
492
+ communityId,
493
+ traceId: resolvedTraceId,
494
+ outcome: "failure",
495
+ pricingCurrency: pricing.currency,
496
+ inputUsdPerMillionTokens: pricing.inputUsdPerMillionTokens,
497
+ outputUsdPerMillionTokens: pricing.outputUsdPerMillionTokens,
498
+ pricingSource: pricing.source,
499
+ pricingVersion: pricing.version,
500
+ }),
501
+ reportTelemetryError,
502
+ );
503
+ throw error;
504
+ }
505
+ };
506
+
507
+ module.exports = { GLOBAL_COMMUNITY_ID, withLlmTelemetry };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@plusscommunities/pluss-llm-telemetry-aws",
3
+ "version": "1.0.0",
4
+ "description": "Dependency-free shared LLM invocation telemetry for Pluss Communities AWS services",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "files": [
8
+ "index.js",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "test": "node --test tests/*.test.js",
13
+ "betapatch": "npm version prepatch --preid=beta",
14
+ "patch": "npm version patch",
15
+ "betaupload": "npm publish --access public --tag beta",
16
+ "betaupload:p": "npm run betapatch && npm run betaupload",
17
+ "upload": "npm publish --access public",
18
+ "upload:p": "npm run patch && npm run upload"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "license": "ISC"
27
+ }