@abbacchio/transport 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +118 -71
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +397 -118
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/transports/bunyan.d.ts +6 -44
- package/dist/transports/bunyan.d.ts.map +1 -1
- package/dist/transports/bunyan.js +9 -62
- package/dist/transports/bunyan.js.map +1 -1
- package/dist/transports/console.d.ts +30 -0
- package/dist/transports/console.d.ts.map +1 -0
- package/dist/transports/console.js +89 -0
- package/dist/transports/console.js.map +1 -0
- package/dist/transports/pino.d.ts +13 -26
- package/dist/transports/pino.d.ts.map +1 -1
- package/dist/transports/pino.js +15 -43
- package/dist/transports/pino.js.map +1 -1
- package/dist/transports/winston.d.ts +6 -46
- package/dist/transports/winston.d.ts.map +1 -1
- package/dist/transports/winston.js +9 -64
- package/dist/transports/winston.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +487 -129
- package/src/index.ts +1 -1
- package/src/tests/histogram.test.ts +189 -0
- package/src/transports/bunyan.ts +9 -68
- package/src/transports/console.ts +116 -0
- package/src/transports/pino.ts +19 -58
- package/src/transports/winston.ts +9 -71
- package/tsconfig.json +1 -1
package/src/client.ts
CHANGED
|
@@ -1,216 +1,574 @@
|
|
|
1
1
|
import { encrypt } from "./encrypt.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* OTLP severity numbers per OpenTelemetry spec.
|
|
5
|
+
* https://opentelemetry.io/docs/specs/otel/logs/data-model/#severity-fields
|
|
6
|
+
*/
|
|
7
|
+
const PINO_TO_OTLP_SEVERITY: Record<number, { number: number; text: string }> = {
|
|
8
|
+
10: { number: 1, text: 'TRACE' },
|
|
9
|
+
20: { number: 5, text: 'DEBUG' },
|
|
10
|
+
30: { number: 9, text: 'INFO' },
|
|
11
|
+
40: { number: 13, text: 'WARN' },
|
|
12
|
+
50: { number: 17, text: 'ERROR' },
|
|
13
|
+
60: { number: 21, text: 'FATAL' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a flat Record to OTLP KeyValue[] attributes.
|
|
18
|
+
*/
|
|
19
|
+
function toOtlpAttributes(obj: Record<string, unknown>): OtlpKeyValue[] {
|
|
20
|
+
const attrs: OtlpKeyValue[] = [];
|
|
21
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
22
|
+
if (val === undefined || val === null) continue;
|
|
23
|
+
attrs.push({ key, value: toOtlpAnyValue(val) });
|
|
24
|
+
}
|
|
25
|
+
return attrs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toOtlpAnyValue(val: unknown): OtlpAnyValue {
|
|
29
|
+
if (typeof val === 'string') return { stringValue: val };
|
|
30
|
+
if (typeof val === 'number') {
|
|
31
|
+
return Number.isInteger(val) ? { intValue: val } : { doubleValue: val };
|
|
32
|
+
}
|
|
33
|
+
if (typeof val === 'boolean') return { boolValue: val };
|
|
34
|
+
return { stringValue: String(val) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface OtlpAnyValue {
|
|
38
|
+
stringValue?: string;
|
|
39
|
+
intValue?: number;
|
|
40
|
+
doubleValue?: number;
|
|
41
|
+
boolValue?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OtlpKeyValue {
|
|
45
|
+
key: string;
|
|
46
|
+
value: OtlpAnyValue;
|
|
47
|
+
}
|
|
48
|
+
|
|
3
49
|
export interface AbbacchioClientOptions {
|
|
4
|
-
/**
|
|
50
|
+
/** Base URL of the OTLP server (e.g. "http://localhost:4002"). Logs go to {endpoint}/v1/logs */
|
|
51
|
+
endpoint?: string;
|
|
52
|
+
/** @deprecated Use `endpoint` instead. If set, used as the full URL for log ingestion. */
|
|
5
53
|
url?: string;
|
|
6
|
-
/** Secret key for encryption. If provided,
|
|
54
|
+
/** Secret key for encryption. If provided, payloads will be encrypted before sending */
|
|
7
55
|
secretKey?: string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/** Number of
|
|
56
|
+
/** Service name — maps to OTLP resource attribute `service.name`. Defaults to 'default' */
|
|
57
|
+
serviceName?: string;
|
|
58
|
+
/** Additional OTLP resource attributes */
|
|
59
|
+
resourceAttributes?: Record<string, string>;
|
|
60
|
+
/** Number of log records to batch before sending. Defaults to 10 */
|
|
13
61
|
batchSize?: number;
|
|
14
62
|
/** Interval in ms between flushes. Defaults to 1000 */
|
|
15
63
|
interval?: number;
|
|
16
64
|
/** Additional headers to send with requests */
|
|
17
65
|
headers?: Record<string, string>;
|
|
18
|
-
/** Whether to send
|
|
66
|
+
/** Whether to send data to the server. Defaults to true */
|
|
19
67
|
enabled?: boolean;
|
|
20
68
|
}
|
|
21
69
|
|
|
22
70
|
/**
|
|
23
|
-
*
|
|
24
|
-
|
|
71
|
+
* A log entry in the format the transports produce before OTLP conversion.
|
|
72
|
+
*/
|
|
73
|
+
export interface LogRecord {
|
|
74
|
+
level?: number;
|
|
75
|
+
msg?: string;
|
|
76
|
+
message?: string;
|
|
77
|
+
time?: number;
|
|
78
|
+
[key: string]: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A metric data point to send via OTLP.
|
|
83
|
+
*/
|
|
84
|
+
export interface MetricRecord {
|
|
85
|
+
name: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
unit?: string;
|
|
88
|
+
type: 'sum' | 'gauge';
|
|
89
|
+
value: number;
|
|
90
|
+
attributes?: Record<string, unknown>;
|
|
91
|
+
isMonotonic?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A histogram data point to send via OTLP.
|
|
96
|
+
*
|
|
97
|
+
* Each record is a single observation. The client accumulates observations
|
|
98
|
+
* into OTLP Histogram data points (with explicit bucket boundaries) on flush.
|
|
99
|
+
*/
|
|
100
|
+
export interface HistogramRecord {
|
|
101
|
+
name: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
unit?: string;
|
|
104
|
+
/** The observed value (e.g. request duration in ms) */
|
|
105
|
+
value: number;
|
|
106
|
+
attributes?: Record<string, unknown>;
|
|
107
|
+
/**
|
|
108
|
+
* Explicit bucket boundaries. Defaults to a standard latency distribution:
|
|
109
|
+
* [5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 10000]
|
|
110
|
+
*/
|
|
111
|
+
explicitBounds?: number[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A trace span to send via OTLP.
|
|
116
|
+
*/
|
|
117
|
+
export interface SpanRecord {
|
|
118
|
+
traceId: string;
|
|
119
|
+
spanId: string;
|
|
120
|
+
parentSpanId?: string;
|
|
121
|
+
name: string;
|
|
122
|
+
kind?: number;
|
|
123
|
+
startTimeUnixNano: string;
|
|
124
|
+
endTimeUnixNano: string;
|
|
125
|
+
attributes?: Record<string, unknown>;
|
|
126
|
+
status?: { code: number; message?: string };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* OTLP-native HTTP client for Abbacchio.
|
|
131
|
+
* Sends logs to POST /v1/logs, metrics to POST /v1/metrics, traces to POST /v1/traces.
|
|
25
132
|
*/
|
|
26
133
|
export class AbbacchioClient {
|
|
27
|
-
private
|
|
134
|
+
private endpoint: string;
|
|
28
135
|
private secretKey?: string;
|
|
29
|
-
private
|
|
30
|
-
private
|
|
136
|
+
private serviceName: string;
|
|
137
|
+
private resourceAttributes: Record<string, string>;
|
|
31
138
|
private batchSize: number;
|
|
32
139
|
private interval: number;
|
|
33
140
|
private headers: Record<string, string>;
|
|
34
141
|
private enabled: boolean;
|
|
35
142
|
|
|
36
|
-
private
|
|
37
|
-
private
|
|
143
|
+
private logBuffer: LogRecord[] = [];
|
|
144
|
+
private metricBuffer: MetricRecord[] = [];
|
|
145
|
+
private histogramBuffer: HistogramRecord[] = [];
|
|
146
|
+
private spanBuffer: SpanRecord[] = [];
|
|
147
|
+
private logTimer: ReturnType<typeof setTimeout> | null = null;
|
|
148
|
+
private metricTimer: ReturnType<typeof setTimeout> | null = null;
|
|
149
|
+
private histogramTimer: ReturnType<typeof setTimeout> | null = null;
|
|
150
|
+
private spanTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
151
|
|
|
39
152
|
constructor(options: AbbacchioClientOptions = {}) {
|
|
40
|
-
|
|
153
|
+
// Support legacy `url` option: if it looks like a full URL with path, strip the path
|
|
154
|
+
if (options.url && !options.endpoint) {
|
|
155
|
+
try {
|
|
156
|
+
const u = new URL(options.url);
|
|
157
|
+
this.endpoint = `${u.protocol}//${u.host}`;
|
|
158
|
+
} catch {
|
|
159
|
+
this.endpoint = options.url;
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
this.endpoint = options.endpoint || 'http://localhost:4002';
|
|
163
|
+
}
|
|
41
164
|
this.secretKey = options.secretKey;
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
165
|
+
this.serviceName = options.serviceName || 'default';
|
|
166
|
+
this.resourceAttributes = options.resourceAttributes || {};
|
|
44
167
|
this.batchSize = options.batchSize || 10;
|
|
45
168
|
this.interval = options.interval || 1000;
|
|
46
169
|
this.headers = options.headers || {};
|
|
47
170
|
this.enabled = options.enabled ?? true;
|
|
48
171
|
}
|
|
49
172
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
setChannel(channel: string | undefined): void {
|
|
54
|
-
this.channel = channel;
|
|
173
|
+
/** Change the service name dynamically */
|
|
174
|
+
setServiceName(name: string): void {
|
|
175
|
+
this.serviceName = name;
|
|
55
176
|
}
|
|
56
177
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
getChannel(): string | undefined {
|
|
61
|
-
return this.channel;
|
|
178
|
+
/** Get the current service name */
|
|
179
|
+
getServiceName(): string {
|
|
180
|
+
return this.serviceName;
|
|
62
181
|
}
|
|
63
182
|
|
|
64
|
-
/**
|
|
65
|
-
* Change the namespace dynamically after initialization
|
|
66
|
-
*/
|
|
67
|
-
setNamespace(namespace: string | undefined): void {
|
|
68
|
-
this.namespace = namespace;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Get the current namespace
|
|
73
|
-
*/
|
|
74
|
-
getNamespace(): string | undefined {
|
|
75
|
-
return this.namespace;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Enable sending logs to the server
|
|
80
|
-
*/
|
|
183
|
+
/** Enable sending data to the server */
|
|
81
184
|
enable(): void {
|
|
82
185
|
this.enabled = true;
|
|
83
186
|
}
|
|
84
187
|
|
|
85
|
-
/**
|
|
86
|
-
* Disable sending logs to the server. Logs will be silently dropped.
|
|
87
|
-
*/
|
|
188
|
+
/** Disable sending data to the server. Data will be silently dropped. */
|
|
88
189
|
disable(): void {
|
|
89
190
|
this.enabled = false;
|
|
90
191
|
}
|
|
91
192
|
|
|
92
|
-
/**
|
|
93
|
-
* Check if the client is currently enabled
|
|
94
|
-
*/
|
|
193
|
+
/** Check if the client is currently enabled */
|
|
95
194
|
isEnabled(): boolean {
|
|
96
195
|
return this.enabled;
|
|
97
196
|
}
|
|
98
197
|
|
|
99
|
-
|
|
100
|
-
* Process a log entry: inject default namespace, then encrypt if needed
|
|
101
|
-
*/
|
|
102
|
-
private processLog(log: unknown): unknown {
|
|
103
|
-
let processed = log;
|
|
198
|
+
// ─── Logs ──────────────────────────────────────────
|
|
104
199
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
200
|
+
/** Add a log record to the buffer */
|
|
201
|
+
add(log: LogRecord | unknown): void {
|
|
202
|
+
if (!this.enabled) return;
|
|
203
|
+
this.logBuffer.push(log as LogRecord);
|
|
204
|
+
|
|
205
|
+
if (this.logBuffer.length >= this.batchSize) {
|
|
206
|
+
this.flushLogs();
|
|
207
|
+
} else {
|
|
208
|
+
this.scheduleLogFlush();
|
|
110
209
|
}
|
|
210
|
+
}
|
|
111
211
|
|
|
112
|
-
|
|
113
|
-
|
|
212
|
+
/** Add multiple log records at once */
|
|
213
|
+
addBatch(logs: (LogRecord | unknown)[]): void {
|
|
214
|
+
if (!this.enabled) return;
|
|
215
|
+
for (const log of logs) {
|
|
216
|
+
this.logBuffer.push(log as LogRecord);
|
|
217
|
+
}
|
|
218
|
+
if (this.logBuffer.length >= this.batchSize) {
|
|
219
|
+
this.flushLogs();
|
|
220
|
+
} else {
|
|
221
|
+
this.scheduleLogFlush();
|
|
114
222
|
}
|
|
115
|
-
return processed;
|
|
116
223
|
}
|
|
117
224
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
225
|
+
/** Send logs immediately without batching */
|
|
226
|
+
async send(logs: (LogRecord | unknown)[]): Promise<void> {
|
|
227
|
+
if (!this.enabled) return;
|
|
228
|
+
await this.sendLogs(logs as LogRecord[]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private scheduleLogFlush(): void {
|
|
232
|
+
if (this.logTimer) return;
|
|
233
|
+
this.logTimer = setTimeout(() => {
|
|
234
|
+
this.logTimer = null;
|
|
235
|
+
this.flushLogs();
|
|
236
|
+
}, this.interval);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async flushLogs(): Promise<void> {
|
|
240
|
+
if (this.logBuffer.length === 0) return;
|
|
241
|
+
const batch = this.logBuffer;
|
|
242
|
+
this.logBuffer = [];
|
|
243
|
+
await this.sendLogs(batch);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async sendLogs(logs: LogRecord[]): Promise<void> {
|
|
247
|
+
const logRecords = logs.map((log) => {
|
|
248
|
+
const { level, msg, message, time, ...extra } = log;
|
|
249
|
+
const pinoLevel = typeof level === 'number' ? level : 30;
|
|
250
|
+
const severity = PINO_TO_OTLP_SEVERITY[pinoLevel] || PINO_TO_OTLP_SEVERITY[30];
|
|
251
|
+
const body = msg || message || '';
|
|
252
|
+
const timeNano = String((time || Date.now()) * 1_000_000);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
timeUnixNano: timeNano,
|
|
256
|
+
observedTimeUnixNano: timeNano,
|
|
257
|
+
severityNumber: severity.number,
|
|
258
|
+
severityText: severity.text,
|
|
259
|
+
body: { stringValue: String(body) },
|
|
260
|
+
attributes: toOtlpAttributes(extra as Record<string, unknown>),
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const payload = {
|
|
265
|
+
resourceLogs: [{
|
|
266
|
+
resource: { attributes: this.buildResourceAttributes() },
|
|
267
|
+
scopeLogs: [{
|
|
268
|
+
scope: { name: '@abbacchio/transport' },
|
|
269
|
+
logRecords,
|
|
270
|
+
}],
|
|
271
|
+
}],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
await this.post('/v1/logs', payload);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Metrics ───────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** Add a metric data point to the buffer */
|
|
280
|
+
addMetric(metric: MetricRecord): void {
|
|
122
281
|
if (!this.enabled) return;
|
|
123
|
-
this.
|
|
282
|
+
this.metricBuffer.push(metric);
|
|
124
283
|
|
|
125
|
-
if (this.
|
|
126
|
-
this.
|
|
284
|
+
if (this.metricBuffer.length >= this.batchSize) {
|
|
285
|
+
this.flushMetrics();
|
|
127
286
|
} else {
|
|
128
|
-
this.
|
|
287
|
+
this.scheduleMetricFlush();
|
|
129
288
|
}
|
|
130
289
|
}
|
|
131
290
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
291
|
+
private scheduleMetricFlush(): void {
|
|
292
|
+
if (this.metricTimer) return;
|
|
293
|
+
this.metricTimer = setTimeout(() => {
|
|
294
|
+
this.metricTimer = null;
|
|
295
|
+
this.flushMetrics();
|
|
296
|
+
}, this.interval);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async flushMetrics(): Promise<void> {
|
|
300
|
+
if (this.metricBuffer.length === 0) return;
|
|
301
|
+
const batch = this.metricBuffer;
|
|
302
|
+
this.metricBuffer = [];
|
|
303
|
+
await this.sendMetrics(batch);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async sendMetrics(metrics: MetricRecord[]): Promise<void> {
|
|
307
|
+
// Group by metric name to build proper OTLP structure
|
|
308
|
+
const byName = new Map<string, MetricRecord[]>();
|
|
309
|
+
for (const m of metrics) {
|
|
310
|
+
const arr = byName.get(m.name) || [];
|
|
311
|
+
arr.push(m);
|
|
312
|
+
byName.set(m.name, arr);
|
|
139
313
|
}
|
|
140
314
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
315
|
+
const otlpMetrics: unknown[] = [];
|
|
316
|
+
for (const [name, records] of byName) {
|
|
317
|
+
const first = records[0];
|
|
318
|
+
const dataPoints = records.map((r) => ({
|
|
319
|
+
timeUnixNano: String(Date.now() * 1_000_000),
|
|
320
|
+
asDouble: r.value,
|
|
321
|
+
attributes: r.attributes ? toOtlpAttributes(r.attributes) : [],
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
const metric: Record<string, unknown> = {
|
|
325
|
+
name,
|
|
326
|
+
description: first.description || '',
|
|
327
|
+
unit: first.unit || '',
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
if (first.type === 'sum') {
|
|
331
|
+
metric.sum = {
|
|
332
|
+
dataPoints,
|
|
333
|
+
aggregationTemporality: 2, // CUMULATIVE
|
|
334
|
+
isMonotonic: first.isMonotonic ?? true,
|
|
335
|
+
};
|
|
336
|
+
} else {
|
|
337
|
+
metric.gauge = { dataPoints };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
otlpMetrics.push(metric);
|
|
145
341
|
}
|
|
342
|
+
|
|
343
|
+
const payload = {
|
|
344
|
+
resourceMetrics: [{
|
|
345
|
+
resource: { attributes: this.buildResourceAttributes() },
|
|
346
|
+
scopeMetrics: [{
|
|
347
|
+
scope: { name: '@abbacchio/transport' },
|
|
348
|
+
metrics: otlpMetrics,
|
|
349
|
+
}],
|
|
350
|
+
}],
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
await this.post('/v1/metrics', payload);
|
|
146
354
|
}
|
|
147
355
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
356
|
+
// ─── Histograms ─────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
private static DEFAULT_BOUNDS = [5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 10000];
|
|
359
|
+
|
|
360
|
+
/** Add a histogram observation to the buffer */
|
|
361
|
+
addHistogram(record: HistogramRecord): void {
|
|
152
362
|
if (!this.enabled) return;
|
|
153
|
-
|
|
154
|
-
|
|
363
|
+
this.histogramBuffer.push(record);
|
|
364
|
+
|
|
365
|
+
if (this.histogramBuffer.length >= this.batchSize) {
|
|
366
|
+
this.flushHistograms();
|
|
367
|
+
} else {
|
|
368
|
+
this.scheduleHistogramFlush();
|
|
369
|
+
}
|
|
155
370
|
}
|
|
156
371
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.timer = setTimeout(() => {
|
|
163
|
-
this.timer = null;
|
|
164
|
-
this.flush();
|
|
372
|
+
private scheduleHistogramFlush(): void {
|
|
373
|
+
if (this.histogramTimer) return;
|
|
374
|
+
this.histogramTimer = setTimeout(() => {
|
|
375
|
+
this.histogramTimer = null;
|
|
376
|
+
this.flushHistograms();
|
|
165
377
|
}, this.interval);
|
|
166
378
|
}
|
|
167
379
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
380
|
+
async flushHistograms(): Promise<void> {
|
|
381
|
+
if (this.histogramBuffer.length === 0) return;
|
|
382
|
+
const batch = this.histogramBuffer;
|
|
383
|
+
this.histogramBuffer = [];
|
|
384
|
+
await this.sendHistograms(batch);
|
|
385
|
+
}
|
|
173
386
|
|
|
174
|
-
|
|
175
|
-
|
|
387
|
+
private async sendHistograms(records: HistogramRecord[]): Promise<void> {
|
|
388
|
+
// Group by name+attributes key to aggregate observations into OTLP histogram data points
|
|
389
|
+
const groups = new Map<string, { records: HistogramRecord[]; bounds: number[] }>();
|
|
390
|
+
for (const r of records) {
|
|
391
|
+
const key = JSON.stringify({ name: r.name, attributes: r.attributes });
|
|
392
|
+
let group = groups.get(key);
|
|
393
|
+
if (!group) {
|
|
394
|
+
group = { records: [], bounds: r.explicitBounds || AbbacchioClient.DEFAULT_BOUNDS };
|
|
395
|
+
groups.set(key, group);
|
|
396
|
+
}
|
|
397
|
+
group.records.push(r);
|
|
398
|
+
}
|
|
176
399
|
|
|
177
|
-
|
|
178
|
-
|
|
400
|
+
const otlpMetrics: unknown[] = [];
|
|
401
|
+
for (const [, group] of groups) {
|
|
402
|
+
const first = group.records[0];
|
|
403
|
+
const bounds = group.bounds;
|
|
404
|
+
const bucketCounts = new Array(bounds.length + 1).fill(0);
|
|
405
|
+
let sum = 0;
|
|
406
|
+
let min = Infinity;
|
|
407
|
+
let max = -Infinity;
|
|
179
408
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
409
|
+
for (const r of group.records) {
|
|
410
|
+
sum += r.value;
|
|
411
|
+
if (r.value < min) min = r.value;
|
|
412
|
+
if (r.value > max) max = r.value;
|
|
413
|
+
|
|
414
|
+
// Find the bucket
|
|
415
|
+
let placed = false;
|
|
416
|
+
for (let i = 0; i < bounds.length; i++) {
|
|
417
|
+
if (r.value <= bounds[i]) {
|
|
418
|
+
bucketCounts[i]++;
|
|
419
|
+
placed = true;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!placed) bucketCounts[bounds.length]++;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
otlpMetrics.push({
|
|
427
|
+
name: first.name,
|
|
428
|
+
description: first.description || '',
|
|
429
|
+
unit: first.unit || '',
|
|
430
|
+
histogram: {
|
|
431
|
+
dataPoints: [{
|
|
432
|
+
timeUnixNano: String(Date.now() * 1_000_000),
|
|
433
|
+
count: group.records.length,
|
|
434
|
+
sum,
|
|
435
|
+
min: min === Infinity ? 0 : min,
|
|
436
|
+
max: max === -Infinity ? 0 : max,
|
|
437
|
+
bucketCounts: bucketCounts.map(String),
|
|
438
|
+
explicitBounds: bounds,
|
|
439
|
+
attributes: first.attributes ? toOtlpAttributes(first.attributes) : [],
|
|
440
|
+
}],
|
|
441
|
+
aggregationTemporality: 1, // DELTA
|
|
192
442
|
},
|
|
193
|
-
body: JSON.stringify({ logs }),
|
|
194
443
|
});
|
|
195
|
-
} catch {
|
|
196
|
-
// Silently fail - don't break the app if Abbacchio server is down
|
|
197
444
|
}
|
|
445
|
+
|
|
446
|
+
const payload = {
|
|
447
|
+
resourceMetrics: [{
|
|
448
|
+
resource: { attributes: this.buildResourceAttributes() },
|
|
449
|
+
scopeMetrics: [{
|
|
450
|
+
scope: { name: '@abbacchio/transport' },
|
|
451
|
+
metrics: otlpMetrics,
|
|
452
|
+
}],
|
|
453
|
+
}],
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
await this.post('/v1/metrics', payload);
|
|
198
457
|
}
|
|
199
458
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (this.
|
|
205
|
-
|
|
206
|
-
|
|
459
|
+
// ─── Traces ────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/** Add a trace span to the buffer */
|
|
462
|
+
addSpan(span: SpanRecord): void {
|
|
463
|
+
if (!this.enabled) return;
|
|
464
|
+
this.spanBuffer.push(span);
|
|
465
|
+
|
|
466
|
+
if (this.spanBuffer.length >= this.batchSize) {
|
|
467
|
+
this.flushSpans();
|
|
468
|
+
} else {
|
|
469
|
+
this.scheduleSpanFlush();
|
|
207
470
|
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private scheduleSpanFlush(): void {
|
|
474
|
+
if (this.spanTimer) return;
|
|
475
|
+
this.spanTimer = setTimeout(() => {
|
|
476
|
+
this.spanTimer = null;
|
|
477
|
+
this.flushSpans();
|
|
478
|
+
}, this.interval);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async flushSpans(): Promise<void> {
|
|
482
|
+
if (this.spanBuffer.length === 0) return;
|
|
483
|
+
const batch = this.spanBuffer;
|
|
484
|
+
this.spanBuffer = [];
|
|
485
|
+
await this.sendSpans(batch);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async sendSpans(spans: SpanRecord[]): Promise<void> {
|
|
489
|
+
const otlpSpans = spans.map((s) => ({
|
|
490
|
+
traceId: s.traceId,
|
|
491
|
+
spanId: s.spanId,
|
|
492
|
+
parentSpanId: s.parentSpanId,
|
|
493
|
+
name: s.name,
|
|
494
|
+
kind: s.kind ?? 1, // SPAN_KIND_INTERNAL
|
|
495
|
+
startTimeUnixNano: s.startTimeUnixNano,
|
|
496
|
+
endTimeUnixNano: s.endTimeUnixNano,
|
|
497
|
+
attributes: s.attributes ? toOtlpAttributes(s.attributes) : [],
|
|
498
|
+
status: s.status,
|
|
499
|
+
}));
|
|
500
|
+
|
|
501
|
+
const payload = {
|
|
502
|
+
resourceSpans: [{
|
|
503
|
+
resource: { attributes: this.buildResourceAttributes() },
|
|
504
|
+
scopeSpans: [{
|
|
505
|
+
scope: { name: '@abbacchio/transport' },
|
|
506
|
+
spans: otlpSpans,
|
|
507
|
+
}],
|
|
508
|
+
}],
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
await this.post('/v1/traces', payload);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ─── Shared ────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
/** Flush all buffers and close the client */
|
|
517
|
+
async flush(): Promise<void> {
|
|
518
|
+
await Promise.all([
|
|
519
|
+
this.flushLogs(),
|
|
520
|
+
this.flushMetrics(),
|
|
521
|
+
this.flushHistograms(),
|
|
522
|
+
this.flushSpans(),
|
|
523
|
+
]);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Close the client and flush any remaining data */
|
|
527
|
+
async close(): Promise<void> {
|
|
528
|
+
if (this.logTimer) { clearTimeout(this.logTimer); this.logTimer = null; }
|
|
529
|
+
if (this.metricTimer) { clearTimeout(this.metricTimer); this.metricTimer = null; }
|
|
530
|
+
if (this.histogramTimer) { clearTimeout(this.histogramTimer); this.histogramTimer = null; }
|
|
531
|
+
if (this.spanTimer) { clearTimeout(this.spanTimer); this.spanTimer = null; }
|
|
208
532
|
await this.flush();
|
|
209
533
|
}
|
|
534
|
+
|
|
535
|
+
private buildResourceAttributes(): OtlpKeyValue[] {
|
|
536
|
+
const attrs: OtlpKeyValue[] = [
|
|
537
|
+
{ key: 'service.name', value: { stringValue: this.serviceName } },
|
|
538
|
+
];
|
|
539
|
+
for (const [key, val] of Object.entries(this.resourceAttributes)) {
|
|
540
|
+
attrs.push({ key, value: { stringValue: val } });
|
|
541
|
+
}
|
|
542
|
+
return attrs;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private async post(path: string, payload: unknown): Promise<void> {
|
|
546
|
+
try {
|
|
547
|
+
let body = JSON.stringify(payload);
|
|
548
|
+
|
|
549
|
+
const headers: Record<string, string> = {
|
|
550
|
+
'Content-Type': 'application/json',
|
|
551
|
+
...this.headers,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
if (this.secretKey) {
|
|
555
|
+
body = encrypt(JSON.stringify(payload), this.secretKey);
|
|
556
|
+
headers['X-Encrypted'] = 'true';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await fetch(`${this.endpoint}${path}`, {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
headers,
|
|
562
|
+
body,
|
|
563
|
+
});
|
|
564
|
+
} catch {
|
|
565
|
+
// Silently fail — don't break the app if the telemetry server is down
|
|
566
|
+
}
|
|
567
|
+
}
|
|
210
568
|
}
|
|
211
569
|
|
|
212
570
|
/**
|
|
213
|
-
* Create a new Abbacchio client instance
|
|
571
|
+
* Create a new Abbacchio OTLP client instance
|
|
214
572
|
*/
|
|
215
573
|
export function createClient(options?: AbbacchioClientOptions): AbbacchioClient {
|
|
216
574
|
return new AbbacchioClient(options);
|