@nxgt/telemetry-otlp 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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/export/errors.d.ts +61 -0
- package/dist/export/errors.d.ts.map +1 -0
- package/dist/export/otlp.d.ts +48 -0
- package/dist/export/otlp.d.ts.map +1 -0
- package/dist/export/transport.d.ts +56 -0
- package/dist/export/transport.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +347 -0
- package/dist/index.js.map +13 -0
- package/dist/wire/convert.d.ts +45 -0
- package/dist/wire/convert.d.ts.map +1 -0
- package/dist/wire/documents.d.ts +109 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# @nxgt/telemetry-otlp
|
|
2
|
+
|
|
3
|
+
Logs and traces from [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry)
|
|
4
|
+
to any OpenTelemetry collector, as OTLP/HTTP JSON — over `fetch`, with no
|
|
5
|
+
OpenTelemetry SDK.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @nxgt/telemetry @nxgt/telemetry-otlp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createTelemetry } from '@nxgt/telemetry';
|
|
13
|
+
import { otlpExporter } from '@nxgt/telemetry-otlp';
|
|
14
|
+
|
|
15
|
+
createTelemetry('checkout', {
|
|
16
|
+
version: '1.4.0',
|
|
17
|
+
environment: 'production',
|
|
18
|
+
exporters: [otlpExporter({ endpoint: 'http://localhost:4318' })],
|
|
19
|
+
}).install();
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What it is
|
|
23
|
+
|
|
24
|
+
OTLP is a **wire format**, not a library. The whole of it a collector needs is
|
|
25
|
+
two JSON documents over HTTP, and that is what this package writes — which is
|
|
26
|
+
why it has one dependency, no transitive tree, and nothing that wants to own
|
|
27
|
+
your context propagation.
|
|
28
|
+
|
|
29
|
+
The alternative is the OpenTelemetry JavaScript SDK: an exporter, a processor, a
|
|
30
|
+
provider, a context manager and a propagator, forty-odd packages between them,
|
|
31
|
+
and a `Context` that has to be wired to `AsyncLocalStorage` anyway. This is the
|
|
32
|
+
same bytes on the socket.
|
|
33
|
+
|
|
34
|
+
Everything it sends is checked against a real HTTP server in the specs, not
|
|
35
|
+
against a `fetch` double: a hand-written wire format has to be read over a wire
|
|
36
|
+
at least once.
|
|
37
|
+
|
|
38
|
+
## The collector
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
otlpExporter({
|
|
42
|
+
endpoint: 'http://localhost:4318',
|
|
43
|
+
headers: { 'x-api-key': key },
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`endpoint` is the base URL. `/v1/logs` and `/v1/traces` are appended, which is
|
|
48
|
+
where every collector listens; `logsPath` and `tracesPath` move them for a
|
|
49
|
+
gateway that mounts OTLP somewhere else.
|
|
50
|
+
|
|
51
|
+
| option | default | |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `endpoint` | — | the collector's base URL |
|
|
54
|
+
| `logsPath` | `/v1/logs` | appended to `endpoint` |
|
|
55
|
+
| `tracesPath` | `/v1/traces` | appended to `endpoint` |
|
|
56
|
+
| `headers` | — | sent on every request — an API key, a tenant |
|
|
57
|
+
| `timeout` | `10s` | per attempt |
|
|
58
|
+
| `attempts` | `3` | how many requests one document gets. `1` disables retrying |
|
|
59
|
+
| `backoff` | `500ms` | the first wait between attempts, doubled each time |
|
|
60
|
+
| `gzip` | `true` | compress a document over 1 KiB |
|
|
61
|
+
| `onPartialSuccess` | — | called when the collector kept the request but not every record |
|
|
62
|
+
| `fetch` | global | for a specs double, or an application that routes its own traffic |
|
|
63
|
+
| `sleep` | `setTimeout` | how the backoff waits. For specs; the default does not hold the process open |
|
|
64
|
+
|
|
65
|
+
## A mixed batch is two requests
|
|
66
|
+
|
|
67
|
+
A batch of logs and spans becomes one `/v1/logs` POST and one `/v1/traces` POST,
|
|
68
|
+
sent together. Logs failing must not cost the traces of the same batch, so both
|
|
69
|
+
are attempted before either failure is thrown. A batch with nothing of one kind
|
|
70
|
+
makes no request of that kind: an empty document is a request worth not making.
|
|
71
|
+
|
|
72
|
+
## When it fails
|
|
73
|
+
|
|
74
|
+
Every failure is thrown out of `export`, which means it reaches
|
|
75
|
+
`onExportError` and nothing else. A collector being down is not a reason for a
|
|
76
|
+
request to fail.
|
|
77
|
+
|
|
78
|
+
| | when | retried |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `OtlpUnreachableError` | no answer at all — DNS, a refused connection, the timeout | yes, to the end of `attempts` |
|
|
81
|
+
| `OtlpRefusedError` | `408`, `429`, `500`, `502`, `503`, `504` | yes, to the end of `attempts` |
|
|
82
|
+
| `OtlpRejectedError` | any other status | **no** |
|
|
83
|
+
|
|
84
|
+
A rejection is not retried because sending the same bytes again gets the same
|
|
85
|
+
answer: the collector is saying the request is wrong, not that it is busy. All
|
|
86
|
+
three carry `url`, `signal` (`'logs'` or `'traces'`) and `attempts`; the two
|
|
87
|
+
that had an answer carry its `status` and the first 500 characters of its body.
|
|
88
|
+
|
|
89
|
+
`url` is the **full request URL** — the endpoint plus the path — with its
|
|
90
|
+
userinfo, query string and fragment removed. A vendor hands out a collector URL
|
|
91
|
+
with the key in one of those often enough that a failure must not be the thing
|
|
92
|
+
that writes it to a log, and a failure is exactly what `onExportError` logs.
|
|
93
|
+
|
|
94
|
+
When both documents fail, for different reasons, both are reported: they go to
|
|
95
|
+
two paths, and a `400` on one with a refused connection on the other is not one
|
|
96
|
+
outage. Two failures arrive as an `AggregateError`; one arrives on its own.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
createTelemetry('checkout', {
|
|
100
|
+
exporters: [otlpExporter({ endpoint })],
|
|
101
|
+
onExportError: (failure) => {
|
|
102
|
+
if (failure instanceof OtlpRejectedError) alert.page(failure); // we are misconfigured
|
|
103
|
+
else metrics.increment('telemetry.export.failed'); // they are down
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Partial success
|
|
109
|
+
|
|
110
|
+
A collector that took the request but threw some records away answers `200`
|
|
111
|
+
with a `partialSuccess`. That is **not** retried — it already accepted
|
|
112
|
+
everything else, and sending the bytes again would duplicate them.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
otlpExporter({
|
|
116
|
+
endpoint,
|
|
117
|
+
onPartialSuccess: ({ signal, rejected, message }) =>
|
|
118
|
+
console.warn(`collector dropped ${rejected} ${signal}: ${message}`),
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The hook is what decides whether the body is read at all: without one, the
|
|
123
|
+
ordinary empty answer is never parsed.
|
|
124
|
+
|
|
125
|
+
## The conversion
|
|
126
|
+
|
|
127
|
+
| this library | OTLP |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| epoch milliseconds | `timeUnixNano`, as a **decimal string** |
|
|
130
|
+
| `TraceId` / `SpanId` | lowercase hex, **not** base64 |
|
|
131
|
+
| `debug` `info` `warn` `error` | `5` `9` `13` `17`, with the name as `severityText` |
|
|
132
|
+
| `internal`…`consumer` | `1`…`5` |
|
|
133
|
+
| `ok` / `error` / `cancelled` | `1` / `2` / **`0`** |
|
|
134
|
+
| the log's `name` | the record `body` |
|
|
135
|
+
| the logger's `source` | one `ScopeLogs` per source |
|
|
136
|
+
| a span | one `ScopeSpans` named `nxgt-telemetry` |
|
|
137
|
+
| `service`, `version`, `environment` | `service.name`, `service.version`, `deployment.environment.name` |
|
|
138
|
+
| `ErrorInfo` | `exception.type`, `exception.message`, `exception.stacktrace`, on logs and spans alike |
|
|
139
|
+
| a whole number | `intValue`, as a string |
|
|
140
|
+
| a fractional number | `doubleValue` |
|
|
141
|
+
| `null` | the unset value, `{}` |
|
|
142
|
+
|
|
143
|
+
`cancelled` maps to **unset**, not error. A shutdown and a timeout are not
|
|
144
|
+
failures, and OTLP has no third code, so the honest answer is to say nothing
|
|
145
|
+
rather than to say "failed".
|
|
146
|
+
|
|
147
|
+
The converter is exported — `logsRequest`, `tracesRequest`, `otlpResource`,
|
|
148
|
+
`anyValue`, `keyValues`, `nanos` — so a transport this package does not have
|
|
149
|
+
(gRPC, a queue, a file of OTLP documents) can be written without redoing it.
|
|
150
|
+
|
|
151
|
+
## API
|
|
152
|
+
|
|
153
|
+
### Exporting
|
|
154
|
+
|
|
155
|
+
| | |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| `otlpExporter(options)` | an `Exporter` that POSTs OTLP/HTTP JSON |
|
|
158
|
+
| `OtlpExporterOptions` | the options above |
|
|
159
|
+
| `PartialSuccessReport` | `{ signal, rejected, message }` |
|
|
160
|
+
| `RETRYABLE` | the statuses worth sending the same bytes again for |
|
|
161
|
+
| `DEFAULT_LOGS_PATH`, `DEFAULT_TRACES_PATH` | `/v1/logs`, `/v1/traces` |
|
|
162
|
+
| `DEFAULT_TIMEOUT`, `DEFAULT_ATTEMPTS`, `DEFAULT_BACKOFF` | `10_000`, `3`, `500` |
|
|
163
|
+
| `COMPRESSION_FLOOR` | `1024` — below it, compressing costs more than it saves |
|
|
164
|
+
| `Transport`, `post`, `encode`, `safeUrl`, `wait` | the transport, for a collector this package does not reach |
|
|
165
|
+
|
|
166
|
+
### Failures
|
|
167
|
+
|
|
168
|
+
| | |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `OtlpError` | the shared base: `url`, `signal`, `attempts` |
|
|
171
|
+
| `OtlpUnreachableError` | no answer; carries the `cause` `fetch` threw |
|
|
172
|
+
| `OtlpRefusedError` | a retryable status that kept coming back; `status`, `body` |
|
|
173
|
+
| `OtlpRejectedError` | a status that will not change; `status`, `body` |
|
|
174
|
+
| `OtlpSignal` | `'logs' \| 'traces'` |
|
|
175
|
+
| `BODY_LIMIT` | `500` — how much of an answer is kept |
|
|
176
|
+
|
|
177
|
+
### Converting
|
|
178
|
+
|
|
179
|
+
| | |
|
|
180
|
+
| --- | --- |
|
|
181
|
+
| `logsRequest(resource, batch)` | the `/v1/logs` document, or `undefined` |
|
|
182
|
+
| `tracesRequest(resource, batch)` | the `/v1/traces` document, or `undefined` |
|
|
183
|
+
| `otlpResource(resource)` | the resource, with the three conventional keys |
|
|
184
|
+
| `anyValue(value)`, `keyValues(attributes)` | an attribute, tagged the way OTLP tags it |
|
|
185
|
+
| `nanos(at)` | epoch milliseconds as decimal nanoseconds |
|
|
186
|
+
| `SPAN_SCOPE`, `STATUS_CODE` | `'nxgt-telemetry'`, the status mapping |
|
|
187
|
+
|
|
188
|
+
### The documents
|
|
189
|
+
|
|
190
|
+
`AnyValue`, `KeyValue`, `OtlpResource`, `InstrumentationScope`,
|
|
191
|
+
`OtlpLogRecord`, `ScopeLogs`, `ResourceLogs`, `ExportLogsServiceRequest`,
|
|
192
|
+
`OtlpSpan`, `OtlpSpanEvent`, `OtlpStatus`, `ScopeSpans`, `ResourceSpans`,
|
|
193
|
+
`ExportTraceServiceRequest`, `PartialSuccess` — types only, hand-written from
|
|
194
|
+
the specification.
|
|
195
|
+
|
|
196
|
+
## Traps
|
|
197
|
+
|
|
198
|
+
- **A whole number past `Number.MAX_SAFE_INTEGER` crosses as a `doubleValue`,
|
|
199
|
+
not an `intValue`.** `Number.isInteger` is true well past the safe range, and
|
|
200
|
+
`String` switches to exponential notation at 1e21 — so `"1e+21"` where OTLP
|
|
201
|
+
asks for a decimal string, or the wrong last digits where it does not. Past
|
|
202
|
+
the safe range a double is all the precision the value had anyway. Put a real
|
|
203
|
+
64-bit identifier in a string attribute.
|
|
204
|
+
- **`nanos` goes through `BigInt`, and has to.** `Date.now() * 1e6` passed
|
|
205
|
+
`Number.MAX_SAFE_INTEGER` in 2001, so the float path answers the same instant
|
|
206
|
+
for two signals a microsecond apart. That is also why the field is text on the
|
|
207
|
+
wire.
|
|
208
|
+
- **Ids are hex, not base64.** Proto3 JSON would encode a `bytes` field as
|
|
209
|
+
base64; the OTLP specification overrides that for `trace_id`, `span_id` and
|
|
210
|
+
`parent_span_id`, and a collector that reads base64 there sees a different
|
|
211
|
+
trace.
|
|
212
|
+
- **A retry is `.unref()`ed**, so a process that has nothing else to do exits
|
|
213
|
+
rather than waiting out a backoff. Telemetry must not be the reason a
|
|
214
|
+
container takes thirty seconds to stop; `close()`'s `drainTimeout` is the
|
|
215
|
+
other half of that.
|
|
216
|
+
- **`Retry-After` is not read.** The backoff is the one this exporter was given,
|
|
217
|
+
doubled. A collector that asks for longer gets the same schedule.
|
|
218
|
+
- **gzip needs `node:zlib`.** A bundle for the browser that shims the builtin
|
|
219
|
+
away still exports — uncompressed, which every collector accepts — rather
|
|
220
|
+
than failing to load.
|
|
221
|
+
- **The batch is gone when `export` throws.** There is no disk queue behind
|
|
222
|
+
this. A collector that is down for longer than the retries costs those
|
|
223
|
+
batches; `fileExporter` beside this one is what an estate that cannot afford
|
|
224
|
+
that uses.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Which document failed. A mixed batch is two requests, and either can fail. */
|
|
2
|
+
export type OtlpSignal = 'logs' | 'traces';
|
|
3
|
+
/** How much of a collector's answer is kept. Enough to read; not a log flood. */
|
|
4
|
+
export declare const BODY_LIMIT = 500;
|
|
5
|
+
/**
|
|
6
|
+
* The shared shape. All three are thrown out of `export`, which means they
|
|
7
|
+
* reach `onExportError` and nothing else: a collector being down is not a
|
|
8
|
+
* reason for a request to fail.
|
|
9
|
+
*
|
|
10
|
+
* They are plain classes with a `name` rather than a discriminated union
|
|
11
|
+
* because that is what a `catch` reads, and `instanceof` across two copies of
|
|
12
|
+
* this package would answer false.
|
|
13
|
+
*/
|
|
14
|
+
export declare abstract class OtlpError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* The request URL — the endpoint and the path — **with any credentials
|
|
17
|
+
* removed**. A vendor's collector URL carries its key in the userinfo or
|
|
18
|
+
* the query string often enough that a failure must not be the thing that
|
|
19
|
+
* writes it to a log.
|
|
20
|
+
*/
|
|
21
|
+
readonly url: string;
|
|
22
|
+
readonly signal: OtlpSignal;
|
|
23
|
+
/** How many requests were made, including the one that failed. */
|
|
24
|
+
readonly attempts: number;
|
|
25
|
+
constructor(message: string, url: string, signal: OtlpSignal, attempts: number);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The collector never answered: DNS, a refused connection, a timeout. Retried
|
|
29
|
+
* to the end of `attempts` before this is thrown.
|
|
30
|
+
*/
|
|
31
|
+
export declare class OtlpUnreachableError extends OtlpError {
|
|
32
|
+
readonly name = "OtlpUnreachableError";
|
|
33
|
+
/** What `fetch` threw on the last attempt. */
|
|
34
|
+
readonly cause: unknown;
|
|
35
|
+
constructor(url: string, signal: OtlpSignal, attempts: number, cause: unknown);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The collector answered, and kept answering, with a status worth retrying —
|
|
39
|
+
* `408`, `429`, or a `5xx` it declares. The queue it was busy with is still
|
|
40
|
+
* busy.
|
|
41
|
+
*/
|
|
42
|
+
export declare class OtlpRefusedError extends OtlpError {
|
|
43
|
+
readonly name = "OtlpRefusedError";
|
|
44
|
+
readonly status: number;
|
|
45
|
+
/** The first `BODY_LIMIT` characters of the last answer. */
|
|
46
|
+
readonly body: string;
|
|
47
|
+
constructor(url: string, signal: OtlpSignal, attempts: number, status: number, body: string);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The collector rejected the request outright — a bad path, a missing header, a
|
|
51
|
+
* document it will not accept. **Not retried**: sending the same bytes again
|
|
52
|
+
* gets the same answer, and the batch is lost either way.
|
|
53
|
+
*/
|
|
54
|
+
export declare class OtlpRejectedError extends OtlpError {
|
|
55
|
+
readonly name = "OtlpRejectedError";
|
|
56
|
+
readonly status: number;
|
|
57
|
+
/** The first `BODY_LIMIT` characters of the answer. */
|
|
58
|
+
readonly body: string;
|
|
59
|
+
constructor(url: string, signal: OtlpSignal, attempts: number, status: number, body: string);
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/export/errors.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE3C,iFAAiF;AACjF,eAAO,MAAM,UAAU,MAAM,CAAC;AAE9B;;;;;;;;GAQG;AACH,8BAAsB,SAAU,SAAQ,KAAK;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAGzB,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,MAAM;CAOjB;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,SAAS;IAClD,SAAkB,IAAI,0BAA0B;IAChD,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;gBAGvB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO;CAUf;AAED;;;;GAIG;AACH,qBAAa,gBAAiB,SAAQ,SAAS;IAC9C,SAAkB,IAAI,sBAAsB;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAGrB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM;CAWb;AAED;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,SAAS;IAC/C,SAAkB,IAAI,uBAAuB;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAGrB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM;CAWb"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Exporter } from '@nxgt/telemetry';
|
|
2
|
+
import { type PartialSuccessReport } from './transport';
|
|
3
|
+
export declare const DEFAULT_LOGS_PATH = "/v1/logs";
|
|
4
|
+
export declare const DEFAULT_TRACES_PATH = "/v1/traces";
|
|
5
|
+
export interface OtlpExporterOptions {
|
|
6
|
+
/** The collector's base URL, e.g. `http://localhost:4318`. */
|
|
7
|
+
readonly endpoint: string;
|
|
8
|
+
/** Appended to `endpoint`. Default `/v1/logs`. */
|
|
9
|
+
readonly logsPath?: string;
|
|
10
|
+
/** Appended to `endpoint`. Default `/v1/traces`. */
|
|
11
|
+
readonly tracesPath?: string;
|
|
12
|
+
/** Sent on every request — an API key, a tenant. */
|
|
13
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
14
|
+
/** Per attempt, in milliseconds. Default 10s. */
|
|
15
|
+
readonly timeout?: number;
|
|
16
|
+
/** How many requests one document gets. Default 3. `1` disables retrying. */
|
|
17
|
+
readonly attempts?: number;
|
|
18
|
+
/** The first wait between attempts, doubled each time. Default 500ms. */
|
|
19
|
+
readonly backoff?: number;
|
|
20
|
+
/** Gzip a document over 1 KiB. Default true. */
|
|
21
|
+
readonly gzip?: boolean;
|
|
22
|
+
/** Called when the collector accepted the request but not every record. */
|
|
23
|
+
readonly onPartialSuccess?: (report: PartialSuccessReport) => void;
|
|
24
|
+
/** For specs, and for an application that routes its own traffic. */
|
|
25
|
+
readonly fetch?: typeof fetch;
|
|
26
|
+
/** For specs. Default a `setTimeout` that does not hold the process open. */
|
|
27
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Logs and traces to an OpenTelemetry collector, as OTLP/HTTP JSON.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* otlpExporter({ endpoint: 'http://localhost:4318', headers: { 'x-api-key': key } })
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* There is no OpenTelemetry SDK behind this. OTLP is a wire format, and the
|
|
37
|
+
* whole of it that a collector needs is two JSON documents over `fetch` — which
|
|
38
|
+
* is what lets this package have one dependency and no transitive tree, and
|
|
39
|
+
* what keeps the context in `AsyncLocalStorage` instead of in the Java SDK's
|
|
40
|
+
* thread-local shape.
|
|
41
|
+
*
|
|
42
|
+
* A mixed batch is **two requests**, one per document, sent together: logs
|
|
43
|
+
* failing must not cost the traces of the same batch. Both are attempted, and
|
|
44
|
+
* both failures are thrown — as an `AggregateError` when there are two —
|
|
45
|
+
* which means they reach `onExportError` and nothing else.
|
|
46
|
+
*/
|
|
47
|
+
export declare function otlpExporter(options: OtlpExporterOptions): Exporter;
|
|
48
|
+
//# sourceMappingURL=otlp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"otlp.d.ts","sourceRoot":"","sources":["../../src/export/otlp.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAoB,MAAM,iBAAiB,CAAC;AAElE,OAAO,EAIN,KAAK,oBAAoB,EAIzB,MAAM,aAAa,CAAC;AAErB,eAAO,MAAM,iBAAiB,aAAa,CAAC;AAC5C,eAAO,MAAM,mBAAmB,eAAe,CAAC;AAEhD,MAAM,WAAW,mBAAmB;IACnC,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oDAAoD;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,iDAAiD;IACjD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,yEAAyE;IACzE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACnE,qEAAqE;IACrE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,QAAQ,CAiCnE"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as zlib from 'node:zlib';
|
|
2
|
+
import { type OtlpSignal } from './errors';
|
|
3
|
+
export declare const DEFAULT_TIMEOUT = 10000;
|
|
4
|
+
export declare const DEFAULT_ATTEMPTS = 3;
|
|
5
|
+
export declare const DEFAULT_BACKOFF = 500;
|
|
6
|
+
/** Below this, compressing costs more than the bytes it saves. */
|
|
7
|
+
export declare const COMPRESSION_FLOOR = 1024;
|
|
8
|
+
/**
|
|
9
|
+
* The statuses worth sending the same bytes again for. Everything else is the
|
|
10
|
+
* collector saying the request is wrong, and a retry would only repeat it.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RETRYABLE: ReadonlySet<number>;
|
|
13
|
+
/** What a collector accepted with reservations. */
|
|
14
|
+
export interface PartialSuccessReport {
|
|
15
|
+
readonly signal: OtlpSignal;
|
|
16
|
+
/** How many records the collector threw away. */
|
|
17
|
+
readonly rejected: number;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
}
|
|
20
|
+
/** Everything `post` needs, resolved once when the exporter is built. */
|
|
21
|
+
export interface Transport {
|
|
22
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
23
|
+
readonly timeout: number;
|
|
24
|
+
readonly attempts: number;
|
|
25
|
+
readonly backoff: number;
|
|
26
|
+
readonly gzip: boolean;
|
|
27
|
+
readonly onPartialSuccess?: (report: PartialSuccessReport) => void;
|
|
28
|
+
readonly fetch: typeof fetch;
|
|
29
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One document to one URL, retried to the end of `attempts`.
|
|
33
|
+
*
|
|
34
|
+
* The body is encoded **once**, before the loop: a retry sends the same bytes,
|
|
35
|
+
* and gzipping them again each time would be work for nothing.
|
|
36
|
+
*/
|
|
37
|
+
export declare function post(transport: Transport, url: string, signal: OtlpSignal, document: unknown): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* The URL as it may be repeated in an error message and read by whatever
|
|
40
|
+
* `onExportError` logs to — **without its credentials**. A collector URL of the
|
|
41
|
+
* shape `https://user:token@otlp.vendor.example/v1/logs` and one with the key
|
|
42
|
+
* in the query string are both things vendors hand out, and a failure is not a
|
|
43
|
+
* reason to put either in a log line.
|
|
44
|
+
*/
|
|
45
|
+
export declare function safeUrl(url: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Gzip when it is worth it, and only where `node:zlib` exists. A bundle for the
|
|
48
|
+
* browser that shims the builtin away still exports — uncompressed, which every
|
|
49
|
+
* collector accepts — rather than failing to load.
|
|
50
|
+
*/
|
|
51
|
+
export declare function encode(json: string, compress: boolean, gzip?: typeof zlib.gzip | null): Promise<{
|
|
52
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
53
|
+
encoded: boolean;
|
|
54
|
+
}>;
|
|
55
|
+
export declare function wait(ms: number): Promise<void>;
|
|
56
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/export/transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,EAIN,KAAK,UAAU,EAEf,MAAM,UAAU,CAAC;AAElB,eAAO,MAAM,eAAe,QAAS,CAAC;AACtC,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,OAAQ,CAAC;AAMvC;;;GAGG;AACH,eAAO,MAAM,SAAS,EAAE,WAAW,CAAC,MAAM,CAExC,CAAC;AAEH,mDAAmD;AACnD,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,iDAAiD;IACjD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,yEAAyE;AACzE,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACnE,QAAQ,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9C;AAED;;;;;GAKG;AACH,wBAAsB,IAAI,CACzB,SAAS,EAAE,SAAS,EACpB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,OAAO,GACf,OAAO,CAAC,IAAI,CAAC,CAgDf;AAwED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAY3C;AAED;;;;GAIG;AACH,wBAAsB,MAAM,CAC3B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,OAAO,EAIjB,IAAI,GAAE,OAAO,IAAI,CAAC,IAAI,GAAG,IAAqB,GAC5C,OAAO,CAAC;IAAE,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAoB/D;AAED,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9C"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC;AAC/B,mBAAmB,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// src/export/errors.ts
|
|
2
|
+
var BODY_LIMIT = 500;
|
|
3
|
+
|
|
4
|
+
class OtlpError extends Error {
|
|
5
|
+
constructor(message, url, signal, attempts) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.url = url;
|
|
8
|
+
this.signal = signal;
|
|
9
|
+
this.attempts = attempts;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class OtlpUnreachableError extends OtlpError {
|
|
14
|
+
constructor(url, signal, attempts, cause) {
|
|
15
|
+
super(`[telemetry] ${url} did not answer for ${signal} after ${attempts} attempt(s)`, url, signal, attempts);
|
|
16
|
+
this.name = "OtlpUnreachableError";
|
|
17
|
+
this.cause = cause;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class OtlpRefusedError extends OtlpError {
|
|
22
|
+
constructor(url, signal, attempts, status, body) {
|
|
23
|
+
super(`[telemetry] ${url} refused ${signal} with ${status} after ${attempts} attempt(s): ${body}`, url, signal, attempts);
|
|
24
|
+
this.name = "OtlpRefusedError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.body = body;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class OtlpRejectedError extends OtlpError {
|
|
31
|
+
constructor(url, signal, attempts, status, body) {
|
|
32
|
+
super(`[telemetry] ${url} rejected ${signal} with ${status}: ${body}`, url, signal, attempts);
|
|
33
|
+
this.name = "OtlpRejectedError";
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.body = body;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// src/wire/convert.ts
|
|
39
|
+
import { SEVERITY_NUMBER, SPAN_KIND_NUMBER } from "@nxgt/telemetry";
|
|
40
|
+
var SPAN_SCOPE = "nxgt-telemetry";
|
|
41
|
+
var STATUS_CODE = Object.freeze({
|
|
42
|
+
ok: 1,
|
|
43
|
+
error: 2,
|
|
44
|
+
cancelled: 0
|
|
45
|
+
});
|
|
46
|
+
function nanos(at) {
|
|
47
|
+
return (BigInt(Math.round(at)) * 1000000n).toString();
|
|
48
|
+
}
|
|
49
|
+
function anyValue(value) {
|
|
50
|
+
if (value === null)
|
|
51
|
+
return {};
|
|
52
|
+
if (typeof value === "string")
|
|
53
|
+
return { stringValue: value };
|
|
54
|
+
if (typeof value === "boolean")
|
|
55
|
+
return { boolValue: value };
|
|
56
|
+
if (typeof value === "number") {
|
|
57
|
+
return Number.isSafeInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
58
|
+
}
|
|
59
|
+
return { arrayValue: { values: value.map(anyValue) } };
|
|
60
|
+
}
|
|
61
|
+
function keyValues(attributes) {
|
|
62
|
+
return Object.keys(attributes).map((key) => ({
|
|
63
|
+
key,
|
|
64
|
+
value: anyValue(attributes[key])
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
function otlpResource(resource) {
|
|
68
|
+
const declared = {
|
|
69
|
+
"service.name": resource.service
|
|
70
|
+
};
|
|
71
|
+
if (resource.version !== undefined) {
|
|
72
|
+
declared["service.version"] = resource.version;
|
|
73
|
+
}
|
|
74
|
+
if (resource.environment !== undefined) {
|
|
75
|
+
declared["deployment.environment.name"] = resource.environment;
|
|
76
|
+
}
|
|
77
|
+
return { attributes: keyValues({ ...declared, ...resource.attributes }) };
|
|
78
|
+
}
|
|
79
|
+
function logsRequest(resource, batch) {
|
|
80
|
+
const bySource = new Map;
|
|
81
|
+
for (const signal of batch) {
|
|
82
|
+
if (signal.type !== "log")
|
|
83
|
+
continue;
|
|
84
|
+
const found = bySource.get(signal.source) ?? [];
|
|
85
|
+
found.push(otlpLog(signal));
|
|
86
|
+
bySource.set(signal.source, found);
|
|
87
|
+
}
|
|
88
|
+
if (bySource.size === 0)
|
|
89
|
+
return;
|
|
90
|
+
const scopeLogs = [...bySource].map(([name, logRecords]) => ({
|
|
91
|
+
scope: { name },
|
|
92
|
+
logRecords
|
|
93
|
+
}));
|
|
94
|
+
return { resourceLogs: [{ resource: otlpResource(resource), scopeLogs }] };
|
|
95
|
+
}
|
|
96
|
+
function tracesRequest(resource, batch) {
|
|
97
|
+
const spans = batch.filter((signal) => signal.type === "span").map(otlpSpan);
|
|
98
|
+
if (spans.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
return {
|
|
101
|
+
resourceSpans: [
|
|
102
|
+
{
|
|
103
|
+
resource: otlpResource(resource),
|
|
104
|
+
scopeSpans: [{ scope: { name: SPAN_SCOPE }, spans }]
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function otlpLog(log) {
|
|
110
|
+
const at = nanos(log.at);
|
|
111
|
+
return {
|
|
112
|
+
timeUnixNano: at,
|
|
113
|
+
observedTimeUnixNano: at,
|
|
114
|
+
severityNumber: SEVERITY_NUMBER[log.severity],
|
|
115
|
+
severityText: log.severity.toUpperCase(),
|
|
116
|
+
body: { stringValue: log.name },
|
|
117
|
+
attributes: keyValues({ ...log.attributes, ...exception(log.error) }),
|
|
118
|
+
...log.span === undefined ? {} : {
|
|
119
|
+
traceId: log.span.traceId,
|
|
120
|
+
spanId: log.span.spanId,
|
|
121
|
+
flags: log.span.sampled ? 1 : 0
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function otlpSpan(span) {
|
|
126
|
+
return {
|
|
127
|
+
traceId: span.context.traceId,
|
|
128
|
+
spanId: span.context.spanId,
|
|
129
|
+
...span.parent === undefined ? {} : { parentSpanId: span.parent },
|
|
130
|
+
name: span.name,
|
|
131
|
+
kind: SPAN_KIND_NUMBER[span.kind],
|
|
132
|
+
startTimeUnixNano: nanos(span.startedAt),
|
|
133
|
+
endTimeUnixNano: nanos(span.endedAt),
|
|
134
|
+
attributes: keyValues({ ...span.attributes, ...exception(span.error) }),
|
|
135
|
+
events: span.events.map((happened) => ({
|
|
136
|
+
timeUnixNano: nanos(happened.at),
|
|
137
|
+
name: happened.name,
|
|
138
|
+
attributes: keyValues(happened.attributes)
|
|
139
|
+
})),
|
|
140
|
+
status: {
|
|
141
|
+
code: STATUS_CODE[span.status],
|
|
142
|
+
...span.error?.message === undefined ? {} : { message: span.error.message }
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function exception(error) {
|
|
147
|
+
if (error === undefined)
|
|
148
|
+
return {};
|
|
149
|
+
return {
|
|
150
|
+
"exception.type": error.type,
|
|
151
|
+
...error.message === undefined ? {} : { "exception.message": error.message },
|
|
152
|
+
...error.stackTrace === undefined ? {} : { "exception.stacktrace": error.stackTrace }
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/export/transport.ts
|
|
157
|
+
import * as zlib from "node:zlib";
|
|
158
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
159
|
+
var DEFAULT_ATTEMPTS = 3;
|
|
160
|
+
var DEFAULT_BACKOFF = 500;
|
|
161
|
+
var COMPRESSION_FLOOR = 1024;
|
|
162
|
+
var AVAILABLE_GZIP = zlib.gzip ?? null;
|
|
163
|
+
var RETRYABLE = new Set([
|
|
164
|
+
408,
|
|
165
|
+
429,
|
|
166
|
+
500,
|
|
167
|
+
502,
|
|
168
|
+
503,
|
|
169
|
+
504
|
|
170
|
+
]);
|
|
171
|
+
async function post(transport, url, signal, document) {
|
|
172
|
+
const body = await encode(JSON.stringify(document), transport.gzip);
|
|
173
|
+
const named = safeUrl(url);
|
|
174
|
+
let failure;
|
|
175
|
+
let waited = transport.backoff;
|
|
176
|
+
for (let attempt = 1;attempt <= transport.attempts; attempt++) {
|
|
177
|
+
if (attempt > 1) {
|
|
178
|
+
await transport.sleep(waited);
|
|
179
|
+
waited *= 2;
|
|
180
|
+
}
|
|
181
|
+
let reply;
|
|
182
|
+
try {
|
|
183
|
+
reply = await transport.fetch(url, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: {
|
|
186
|
+
"content-type": "application/json",
|
|
187
|
+
...body.encoded ? { "content-encoding": "gzip" } : {},
|
|
188
|
+
...transport.headers
|
|
189
|
+
},
|
|
190
|
+
body: body.bytes,
|
|
191
|
+
signal: AbortSignal.timeout(transport.timeout)
|
|
192
|
+
});
|
|
193
|
+
} catch (thrown) {
|
|
194
|
+
failure = thrown;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (reply.ok) {
|
|
198
|
+
await accepted(transport, signal, reply);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const text = truncate(await bodyOf(reply));
|
|
202
|
+
if (!RETRYABLE.has(reply.status)) {
|
|
203
|
+
throw new OtlpRejectedError(named, signal, attempt, reply.status, text);
|
|
204
|
+
}
|
|
205
|
+
failure = new OtlpRefusedError(named, signal, attempt, reply.status, text);
|
|
206
|
+
}
|
|
207
|
+
if (failure instanceof OtlpRefusedError)
|
|
208
|
+
throw failure;
|
|
209
|
+
throw new OtlpUnreachableError(named, signal, transport.attempts, failure);
|
|
210
|
+
}
|
|
211
|
+
async function accepted(transport, signal, reply) {
|
|
212
|
+
if (transport.onPartialSuccess === undefined) {
|
|
213
|
+
await reply.body?.cancel().catch(() => {
|
|
214
|
+
return;
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
report(signal, await partialSuccess(reply), transport.onPartialSuccess);
|
|
219
|
+
}
|
|
220
|
+
function report(signal, found, onPartialSuccess) {
|
|
221
|
+
if (found === undefined)
|
|
222
|
+
return;
|
|
223
|
+
const rejected = Number(found.rejectedLogRecords ?? found.rejectedSpans ?? 0);
|
|
224
|
+
const message = found.errorMessage ?? "";
|
|
225
|
+
if (rejected === 0 && message === "")
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
onPartialSuccess({ signal, rejected, message });
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
async function partialSuccess(reply) {
|
|
232
|
+
try {
|
|
233
|
+
const answered = await reply.json();
|
|
234
|
+
return answered?.partialSuccess;
|
|
235
|
+
} catch {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function bodyOf(reply) {
|
|
240
|
+
try {
|
|
241
|
+
return await reply.text();
|
|
242
|
+
} catch {
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function truncate(text) {
|
|
247
|
+
return text.length <= BODY_LIMIT ? text : `${text.slice(0, BODY_LIMIT)}…`;
|
|
248
|
+
}
|
|
249
|
+
function safeUrl(url) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = new URL(url);
|
|
252
|
+
parsed.username = "";
|
|
253
|
+
parsed.password = "";
|
|
254
|
+
parsed.search = "";
|
|
255
|
+
parsed.hash = "";
|
|
256
|
+
return parsed.toString();
|
|
257
|
+
} catch {
|
|
258
|
+
return url;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function encode(json, compress, gzip = AVAILABLE_GZIP) {
|
|
262
|
+
const bytes = new TextEncoder().encode(json);
|
|
263
|
+
if (!compress || bytes.byteLength < COMPRESSION_FLOOR) {
|
|
264
|
+
return { bytes, encoded: false };
|
|
265
|
+
}
|
|
266
|
+
if (typeof gzip !== "function")
|
|
267
|
+
return { bytes, encoded: false };
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
gzip(bytes, (failure, result) => {
|
|
270
|
+
resolve(failure ? { bytes, encoded: false } : { bytes: result, encoded: true });
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function wait(ms) {
|
|
275
|
+
return new Promise((resolve) => {
|
|
276
|
+
const timer = setTimeout(resolve, ms);
|
|
277
|
+
timer.unref?.();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/export/otlp.ts
|
|
282
|
+
var DEFAULT_LOGS_PATH = "/v1/logs";
|
|
283
|
+
var DEFAULT_TRACES_PATH = "/v1/traces";
|
|
284
|
+
function otlpExporter(options) {
|
|
285
|
+
const endpoint = options.endpoint.replace(/\/+$/, "");
|
|
286
|
+
const logsUrl = endpoint + (options.logsPath ?? DEFAULT_LOGS_PATH);
|
|
287
|
+
const tracesUrl = endpoint + (options.tracesPath ?? DEFAULT_TRACES_PATH);
|
|
288
|
+
const transport = {
|
|
289
|
+
headers: options.headers ?? {},
|
|
290
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT,
|
|
291
|
+
attempts: Math.max(options.attempts ?? DEFAULT_ATTEMPTS, 1),
|
|
292
|
+
backoff: options.backoff ?? DEFAULT_BACKOFF,
|
|
293
|
+
gzip: options.gzip ?? true,
|
|
294
|
+
...options.onPartialSuccess === undefined ? {} : { onPartialSuccess: options.onPartialSuccess },
|
|
295
|
+
fetch: options.fetch ?? fetch,
|
|
296
|
+
sleep: options.sleep ?? wait
|
|
297
|
+
};
|
|
298
|
+
return {
|
|
299
|
+
async export(resource, batch) {
|
|
300
|
+
const logs = logsRequest(resource, batch);
|
|
301
|
+
const traces = tracesRequest(resource, batch);
|
|
302
|
+
const sent = await Promise.allSettled([
|
|
303
|
+
logs === undefined ? undefined : post(transport, logsUrl, "logs", logs),
|
|
304
|
+
traces === undefined ? undefined : post(transport, tracesUrl, "traces", traces)
|
|
305
|
+
]);
|
|
306
|
+
throwAll(sent);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function throwAll(sent) {
|
|
311
|
+
const failures = sent.filter((one) => one.status === "rejected").map((one) => one.reason);
|
|
312
|
+
if (failures.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
if (failures.length === 1)
|
|
315
|
+
throw failures[0];
|
|
316
|
+
throw new AggregateError(failures, "[telemetry] neither the logs nor the traces of this batch arrived");
|
|
317
|
+
}
|
|
318
|
+
export {
|
|
319
|
+
BODY_LIMIT,
|
|
320
|
+
COMPRESSION_FLOOR,
|
|
321
|
+
DEFAULT_ATTEMPTS,
|
|
322
|
+
DEFAULT_BACKOFF,
|
|
323
|
+
DEFAULT_LOGS_PATH,
|
|
324
|
+
DEFAULT_TIMEOUT,
|
|
325
|
+
DEFAULT_TRACES_PATH,
|
|
326
|
+
OtlpError,
|
|
327
|
+
OtlpRefusedError,
|
|
328
|
+
OtlpRejectedError,
|
|
329
|
+
OtlpUnreachableError,
|
|
330
|
+
RETRYABLE,
|
|
331
|
+
SPAN_SCOPE,
|
|
332
|
+
STATUS_CODE,
|
|
333
|
+
anyValue,
|
|
334
|
+
encode,
|
|
335
|
+
keyValues,
|
|
336
|
+
logsRequest,
|
|
337
|
+
nanos,
|
|
338
|
+
otlpExporter,
|
|
339
|
+
otlpResource,
|
|
340
|
+
post,
|
|
341
|
+
safeUrl,
|
|
342
|
+
tracesRequest,
|
|
343
|
+
wait
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
//# debugId=4685C21DF9219CEA64756E2164756E21
|
|
347
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/export/errors.ts", "../src/wire/convert.ts", "../src/export/transport.ts", "../src/export/otlp.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/** Which document failed. A mixed batch is two requests, and either can fail. */\nexport type OtlpSignal = 'logs' | 'traces';\n\n/** How much of a collector's answer is kept. Enough to read; not a log flood. */\nexport const BODY_LIMIT = 500;\n\n/**\n * The shared shape. All three are thrown out of `export`, which means they\n * reach `onExportError` and nothing else: a collector being down is not a\n * reason for a request to fail.\n *\n * They are plain classes with a `name` rather than a discriminated union\n * because that is what a `catch` reads, and `instanceof` across two copies of\n * this package would answer false.\n */\nexport abstract class OtlpError extends Error {\n\t/**\n\t * The request URL — the endpoint and the path — **with any credentials\n\t * removed**. A vendor's collector URL carries its key in the userinfo or\n\t * the query string often enough that a failure must not be the thing that\n\t * writes it to a log.\n\t */\n\treadonly url: string;\n\treadonly signal: OtlpSignal;\n\t/** How many requests were made, including the one that failed. */\n\treadonly attempts: number;\n\n\tconstructor(\n\t\tmessage: string,\n\t\turl: string,\n\t\tsignal: OtlpSignal,\n\t\tattempts: number,\n\t) {\n\t\tsuper(message);\n\t\tthis.url = url;\n\t\tthis.signal = signal;\n\t\tthis.attempts = attempts;\n\t}\n}\n\n/**\n * The collector never answered: DNS, a refused connection, a timeout. Retried\n * to the end of `attempts` before this is thrown.\n */\nexport class OtlpUnreachableError extends OtlpError {\n\toverride readonly name = 'OtlpUnreachableError';\n\t/** What `fetch` threw on the last attempt. */\n\treadonly cause: unknown;\n\n\tconstructor(\n\t\turl: string,\n\t\tsignal: OtlpSignal,\n\t\tattempts: number,\n\t\tcause: unknown,\n\t) {\n\t\tsuper(\n\t\t\t`[telemetry] ${url} did not answer for ${signal} after ${attempts} attempt(s)`,\n\t\t\turl,\n\t\t\tsignal,\n\t\t\tattempts,\n\t\t);\n\t\tthis.cause = cause;\n\t}\n}\n\n/**\n * The collector answered, and kept answering, with a status worth retrying —\n * `408`, `429`, or a `5xx` it declares. The queue it was busy with is still\n * busy.\n */\nexport class OtlpRefusedError extends OtlpError {\n\toverride readonly name = 'OtlpRefusedError';\n\treadonly status: number;\n\t/** The first `BODY_LIMIT` characters of the last answer. */\n\treadonly body: string;\n\n\tconstructor(\n\t\turl: string,\n\t\tsignal: OtlpSignal,\n\t\tattempts: number,\n\t\tstatus: number,\n\t\tbody: string,\n\t) {\n\t\tsuper(\n\t\t\t`[telemetry] ${url} refused ${signal} with ${status} after ${attempts} attempt(s): ${body}`,\n\t\t\turl,\n\t\t\tsignal,\n\t\t\tattempts,\n\t\t);\n\t\tthis.status = status;\n\t\tthis.body = body;\n\t}\n}\n\n/**\n * The collector rejected the request outright — a bad path, a missing header, a\n * document it will not accept. **Not retried**: sending the same bytes again\n * gets the same answer, and the batch is lost either way.\n */\nexport class OtlpRejectedError extends OtlpError {\n\toverride readonly name = 'OtlpRejectedError';\n\treadonly status: number;\n\t/** The first `BODY_LIMIT` characters of the answer. */\n\treadonly body: string;\n\n\tconstructor(\n\t\turl: string,\n\t\tsignal: OtlpSignal,\n\t\tattempts: number,\n\t\tstatus: number,\n\t\tbody: string,\n\t) {\n\t\tsuper(\n\t\t\t`[telemetry] ${url} rejected ${signal} with ${status}: ${body}`,\n\t\t\turl,\n\t\t\tsignal,\n\t\t\tattempts,\n\t\t);\n\t\tthis.status = status;\n\t\tthis.body = body;\n\t}\n}\n",
|
|
6
|
+
"import type {\n\tAttributes,\n\tAttributeValue,\n\tErrorInfo,\n\tLogRecord,\n\tResource,\n\tSignal,\n\tSpanRecord,\n\tSpanStatus,\n} from '@nxgt/telemetry';\nimport { SEVERITY_NUMBER, SPAN_KIND_NUMBER } from '@nxgt/telemetry';\nimport type {\n\tAnyValue,\n\tExportLogsServiceRequest,\n\tExportTraceServiceRequest,\n\tKeyValue,\n\tOtlpLogRecord,\n\tOtlpResource,\n\tOtlpSpan,\n\tOtlpSpanEvent,\n\tScopeLogs,\n} from './documents';\n\n/** The instrumentation scope reported for spans. Logs report their `source`. */\nexport const SPAN_SCOPE = 'nxgt-telemetry';\n\n/**\n * `ok` maps to `1` and `error` to `2`, and **`cancelled` maps to `0`** — unset,\n * not error. A shutdown and a timeout are not failures, and a dashboard that\n * counts them as such is a dashboard nobody trusts; OTLP has no third code, so\n * the honest answer is to say nothing rather than to say \"failed\".\n */\nexport const STATUS_CODE: Readonly<Record<SpanStatus, number>> = Object.freeze({\n\tok: 1,\n\terror: 2,\n\tcancelled: 0,\n});\n\n/**\n * Epoch milliseconds to the decimal nanoseconds OTLP wants, through `BigInt`.\n *\n * A `number` would be wrong from 2001 onwards: `Date.now() * 1e6` is well past\n * `Number.MAX_SAFE_INTEGER`, so the last digits are whatever the float rounds\n * to, and two signals a microsecond apart arrive with the same timestamp.\n */\nexport function nanos(at: number): string {\n\treturn (BigInt(Math.round(at)) * 1_000_000n).toString();\n}\n\n/** A scalar, tagged the way OTLP tags it. */\nexport function anyValue(value: AttributeValue): AnyValue {\n\tif (value === null) return {};\n\tif (typeof value === 'string') return { stringValue: value };\n\tif (typeof value === 'boolean') return { boolValue: value };\n\n\tif (typeof value === 'number') {\n\t\t// A whole number is an integer to every backend that groups by it, and\n\t\t// it crosses as text so that the low bits survive.\n\t\t//\n\t\t// **Safe**, not merely whole: `Number.isInteger` is true well past the\n\t\t// safe range, and `String` switches to exponential notation at 1e21.\n\t\t// `1e21` would be sent as `\"1e+21\"`, which is not the decimal string\n\t\t// OTLP asks for; `1e20` is decimal but past `int64`; and `2 ** 60`\n\t\t// would arrive with the wrong last three digits. A collector rejects\n\t\t// the first two with a `400`, which is not retried — so one attribute\n\t\t// would lose the whole document. Past the safe range the honest answer\n\t\t// is a double, which is all the precision the value had anyway.\n\t\treturn Number.isSafeInteger(value)\n\t\t\t? { intValue: String(value) }\n\t\t\t: { doubleValue: value };\n\t}\n\n\treturn { arrayValue: { values: value.map(anyValue) } };\n}\n\nexport function keyValues(attributes: Attributes): KeyValue[] {\n\treturn Object.keys(attributes).map((key) => ({\n\t\tkey,\n\t\tvalue: anyValue(attributes[key] as AttributeValue),\n\t}));\n}\n\n/**\n * The resource, with `service.name`, `service.version` and\n * `deployment.environment.name` — the three keys every backend looks for, under\n * the names the semantic conventions give them. An attribute already carrying\n * one of those keys wins: it was set deliberately.\n */\nexport function otlpResource(resource: Resource): OtlpResource {\n\tconst declared: Record<string, AttributeValue> = {\n\t\t'service.name': resource.service,\n\t};\n\tif (resource.version !== undefined) {\n\t\tdeclared['service.version'] = resource.version;\n\t}\n\tif (resource.environment !== undefined) {\n\t\tdeclared['deployment.environment.name'] = resource.environment;\n\t}\n\n\treturn { attributes: keyValues({ ...declared, ...resource.attributes }) };\n}\n\n/**\n * The logs of a batch, **grouped by `source`** into one scope each: the logger's\n * name is the instrumentation scope, which is what lets a backend filter by the\n * component that wrote the line rather than by the service that ran it.\n *\n * `undefined` when there is nothing to send — an empty document is a request\n * worth not making.\n */\nexport function logsRequest(\n\tresource: Resource,\n\tbatch: readonly Signal[],\n): ExportLogsServiceRequest | undefined {\n\tconst bySource = new Map<string, OtlpLogRecord[]>();\n\n\tfor (const signal of batch) {\n\t\tif (signal.type !== 'log') continue;\n\t\tconst found = bySource.get(signal.source) ?? [];\n\t\tfound.push(otlpLog(signal));\n\t\tbySource.set(signal.source, found);\n\t}\n\n\tif (bySource.size === 0) return undefined;\n\n\tconst scopeLogs: ScopeLogs[] = [...bySource].map(([name, logRecords]) => ({\n\t\tscope: { name },\n\t\tlogRecords,\n\t}));\n\n\treturn { resourceLogs: [{ resource: otlpResource(resource), scopeLogs }] };\n}\n\n/**\n * The spans of a batch, in one scope named for this library. A span has no\n * `source` — it is named for the work, not for the component — so there is\n * nothing to group by.\n */\nexport function tracesRequest(\n\tresource: Resource,\n\tbatch: readonly Signal[],\n): ExportTraceServiceRequest | undefined {\n\tconst spans = batch\n\t\t.filter((signal): signal is SpanRecord => signal.type === 'span')\n\t\t.map(otlpSpan);\n\n\tif (spans.length === 0) return undefined;\n\n\treturn {\n\t\tresourceSpans: [\n\t\t\t{\n\t\t\t\tresource: otlpResource(resource),\n\t\t\t\tscopeSpans: [{ scope: { name: SPAN_SCOPE }, spans }],\n\t\t\t},\n\t\t],\n\t};\n}\n\nfunction otlpLog(log: LogRecord): OtlpLogRecord {\n\tconst at = nanos(log.at);\n\n\treturn {\n\t\ttimeUnixNano: at,\n\t\t// The same instant: this library records when the line was written, and\n\t\t// has nothing else to observe it at.\n\t\tobservedTimeUnixNano: at,\n\t\tseverityNumber: SEVERITY_NUMBER[log.severity],\n\t\tseverityText: log.severity.toUpperCase(),\n\t\t// The body is the event's name, or the message when there is no type\n\t\t// for it yet. Everything structured is an attribute.\n\t\tbody: { stringValue: log.name },\n\t\tattributes: keyValues({ ...log.attributes, ...exception(log.error) }),\n\t\t...(log.span === undefined\n\t\t\t? {}\n\t\t\t: {\n\t\t\t\t\ttraceId: log.span.traceId,\n\t\t\t\t\tspanId: log.span.spanId,\n\t\t\t\t\tflags: log.span.sampled ? 1 : 0,\n\t\t\t\t}),\n\t};\n}\n\nfunction otlpSpan(span: SpanRecord): OtlpSpan {\n\treturn {\n\t\ttraceId: span.context.traceId,\n\t\tspanId: span.context.spanId,\n\t\t...(span.parent === undefined ? {} : { parentSpanId: span.parent }),\n\t\tname: span.name,\n\t\tkind: SPAN_KIND_NUMBER[span.kind],\n\t\tstartTimeUnixNano: nanos(span.startedAt),\n\t\tendTimeUnixNano: nanos(span.endedAt),\n\t\tattributes: keyValues({ ...span.attributes, ...exception(span.error) }),\n\t\tevents: span.events.map(\n\t\t\t(happened): OtlpSpanEvent => ({\n\t\t\t\ttimeUnixNano: nanos(happened.at),\n\t\t\t\tname: happened.name,\n\t\t\t\tattributes: keyValues(happened.attributes),\n\t\t\t}),\n\t\t),\n\t\tstatus: {\n\t\t\tcode: STATUS_CODE[span.status],\n\t\t\t...(span.error?.message === undefined\n\t\t\t\t? {}\n\t\t\t\t: { message: span.error.message }),\n\t\t},\n\t};\n}\n\n/**\n * A failure travels as attributes, under the semantic convention's keys, on a\n * log and on a span alike. There is no dedicated field for it in either\n * document.\n */\nfunction exception(error: ErrorInfo | undefined): Attributes {\n\tif (error === undefined) return {};\n\n\treturn {\n\t\t'exception.type': error.type,\n\t\t...(error.message === undefined\n\t\t\t? {}\n\t\t\t: { 'exception.message': error.message }),\n\t\t...(error.stackTrace === undefined\n\t\t\t? {}\n\t\t\t: { 'exception.stacktrace': error.stackTrace }),\n\t};\n}\n",
|
|
7
|
+
"import * as zlib from 'node:zlib';\nimport type { PartialSuccess } from '../wire/documents';\nimport {\n\tBODY_LIMIT,\n\tOtlpRefusedError,\n\tOtlpRejectedError,\n\ttype OtlpSignal,\n\tOtlpUnreachableError,\n} from './errors';\n\nexport const DEFAULT_TIMEOUT = 10_000;\nexport const DEFAULT_ATTEMPTS = 3;\nexport const DEFAULT_BACKOFF = 500;\n/** Below this, compressing costs more than the bytes it saves. */\nexport const COMPRESSION_FLOOR = 1_024;\n\n/** `null` where `node:zlib` was shimmed away — a browser bundle, usually. */\nconst AVAILABLE_GZIP: typeof zlib.gzip | null =\n\t(zlib as Partial<typeof zlib>).gzip ?? null;\n\n/**\n * The statuses worth sending the same bytes again for. Everything else is the\n * collector saying the request is wrong, and a retry would only repeat it.\n */\nexport const RETRYABLE: ReadonlySet<number> = new Set([\n\t408, 429, 500, 502, 503, 504,\n]);\n\n/** What a collector accepted with reservations. */\nexport interface PartialSuccessReport {\n\treadonly signal: OtlpSignal;\n\t/** How many records the collector threw away. */\n\treadonly rejected: number;\n\treadonly message: string;\n}\n\n/** Everything `post` needs, resolved once when the exporter is built. */\nexport interface Transport {\n\treadonly headers: Readonly<Record<string, string>>;\n\treadonly timeout: number;\n\treadonly attempts: number;\n\treadonly backoff: number;\n\treadonly gzip: boolean;\n\treadonly onPartialSuccess?: (report: PartialSuccessReport) => void;\n\treadonly fetch: typeof fetch;\n\treadonly sleep: (ms: number) => Promise<void>;\n}\n\n/**\n * One document to one URL, retried to the end of `attempts`.\n *\n * The body is encoded **once**, before the loop: a retry sends the same bytes,\n * and gzipping them again each time would be work for nothing.\n */\nexport async function post(\n\ttransport: Transport,\n\turl: string,\n\tsignal: OtlpSignal,\n\tdocument: unknown,\n): Promise<void> {\n\tconst body = await encode(JSON.stringify(document), transport.gzip);\n\tconst named = safeUrl(url);\n\n\tlet failure: unknown;\n\tlet waited = transport.backoff;\n\n\tfor (let attempt = 1; attempt <= transport.attempts; attempt++) {\n\t\tif (attempt > 1) {\n\t\t\tawait transport.sleep(waited);\n\t\t\twaited *= 2;\n\t\t}\n\n\t\tlet reply: Response;\n\t\ttry {\n\t\t\treply = await transport.fetch(url, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'content-type': 'application/json',\n\t\t\t\t\t...(body.encoded ? { 'content-encoding': 'gzip' } : {}),\n\t\t\t\t\t...transport.headers,\n\t\t\t\t},\n\t\t\t\tbody: body.bytes,\n\t\t\t\tsignal: AbortSignal.timeout(transport.timeout),\n\t\t\t});\n\t\t} catch (thrown) {\n\t\t\t// No answer at all: DNS, a refused connection, the timeout.\n\t\t\tfailure = thrown;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (reply.ok) {\n\t\t\tawait accepted(transport, signal, reply);\n\t\t\treturn;\n\t\t}\n\n\t\tconst text = truncate(await bodyOf(reply));\n\n\t\tif (!RETRYABLE.has(reply.status)) {\n\t\t\t// The same bytes would get the same answer.\n\t\t\tthrow new OtlpRejectedError(named, signal, attempt, reply.status, text);\n\t\t}\n\n\t\tfailure = new OtlpRefusedError(named, signal, attempt, reply.status, text);\n\t}\n\n\tif (failure instanceof OtlpRefusedError) throw failure;\n\tthrow new OtlpUnreachableError(named, signal, transport.attempts, failure);\n}\n\n/**\n * The collector took the request. Its body still has to be read or cancelled:\n * an unconsumed `Response` holds its connection out of the keep-alive pool\n * until it is collected, and this is the path every successful export takes,\n * once per linger interval, for the life of the process.\n */\nasync function accepted(\n\ttransport: Transport,\n\tsignal: OtlpSignal,\n\treply: Response,\n): Promise<void> {\n\tif (transport.onPartialSuccess === undefined) {\n\t\t// Nobody asked what it said, and the ordinary answer is empty.\n\t\tawait reply.body?.cancel().catch(() => undefined);\n\t\treturn;\n\t}\n\n\treport(signal, await partialSuccess(reply), transport.onPartialSuccess);\n}\n\n/**\n * A partial success is **not retried**. The collector took the request and\n * decided about each record in it; sending the same bytes again would duplicate\n * everything it did accept.\n */\nfunction report(\n\tsignal: OtlpSignal,\n\tfound: PartialSuccess | undefined,\n\tonPartialSuccess: (report: PartialSuccessReport) => void,\n): void {\n\tif (found === undefined) return;\n\n\tconst rejected = Number(found.rejectedLogRecords ?? found.rejectedSpans ?? 0);\n\tconst message = found.errorMessage ?? '';\n\tif (rejected === 0 && message === '') return;\n\n\ttry {\n\t\tonPartialSuccess({ signal, rejected, message });\n\t} catch {\n\t\t// A reporting hook that throws must not become an export failure.\n\t}\n}\n\nasync function partialSuccess(\n\treply: Response,\n): Promise<PartialSuccess | undefined> {\n\ttry {\n\t\tconst answered = (await reply.json()) as {\n\t\t\tpartialSuccess?: PartialSuccess;\n\t\t} | null;\n\t\treturn answered?.partialSuccess;\n\t} catch {\n\t\t// An empty body is the ordinary answer, and a collector that sends\n\t\t// something else has still accepted the batch.\n\t\treturn undefined;\n\t}\n}\n\nasync function bodyOf(reply: Response): Promise<string> {\n\ttry {\n\t\treturn await reply.text();\n\t} catch {\n\t\treturn '';\n\t}\n}\n\nfunction truncate(text: string): string {\n\treturn text.length <= BODY_LIMIT ? text : `${text.slice(0, BODY_LIMIT)}…`;\n}\n\n/**\n * The URL as it may be repeated in an error message and read by whatever\n * `onExportError` logs to — **without its credentials**. A collector URL of the\n * shape `https://user:token@otlp.vendor.example/v1/logs` and one with the key\n * in the query string are both things vendors hand out, and a failure is not a\n * reason to put either in a log line.\n */\nexport function safeUrl(url: string): string {\n\ttry {\n\t\tconst parsed = new URL(url);\n\t\tparsed.username = '';\n\t\tparsed.password = '';\n\t\tparsed.search = '';\n\t\tparsed.hash = '';\n\t\treturn parsed.toString();\n\t} catch {\n\t\t// Not a URL this runtime can parse; `fetch` will say so in its own way.\n\t\treturn url;\n\t}\n}\n\n/**\n * Gzip when it is worth it, and only where `node:zlib` exists. A bundle for the\n * browser that shims the builtin away still exports — uncompressed, which every\n * collector accepts — rather than failing to load.\n */\nexport async function encode(\n\tjson: string,\n\tcompress: boolean,\n\t// `null` is \"there is no gzip here\", which is what a bundler that shims\n\t// `node:zlib` to an empty module leaves behind, whatever the types say. It\n\t// is a parameter so a spec can reach that branch without mocking a builtin.\n\tgzip: typeof zlib.gzip | null = AVAILABLE_GZIP,\n): Promise<{ bytes: Uint8Array<ArrayBuffer>; encoded: boolean }> {\n\tconst bytes = new TextEncoder().encode(json);\n\tif (!compress || bytes.byteLength < COMPRESSION_FLOOR) {\n\t\treturn { bytes, encoded: false };\n\t}\n\n\tif (typeof gzip !== 'function') return { bytes, encoded: false };\n\n\treturn new Promise((resolve) => {\n\t\tgzip(bytes, (failure, result) => {\n\t\t\tresolve(\n\t\t\t\tfailure\n\t\t\t\t\t? { bytes, encoded: false }\n\t\t\t\t\t: // `zlib` answers a `Buffer`, whose buffer is typed as\n\t\t\t\t\t\t// `ArrayBufferLike` — which `BodyInit` excludes because it\n\t\t\t\t\t\t// admits a `SharedArrayBuffer`. This one never is.\n\t\t\t\t\t\t{ bytes: result as Uint8Array<ArrayBuffer>, encoded: true },\n\t\t\t);\n\t\t});\n\t});\n}\n\nexport function wait(ms: number): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\tconst timer = setTimeout(resolve, ms);\n\t\t// A retry in flight must not be the reason a process will not exit.\n\t\ttimer.unref?.();\n\t});\n}\n",
|
|
8
|
+
"import type { Exporter, Resource, Signal } from '@nxgt/telemetry';\nimport { logsRequest, tracesRequest } from '../wire/convert';\nimport {\n\tDEFAULT_ATTEMPTS,\n\tDEFAULT_BACKOFF,\n\tDEFAULT_TIMEOUT,\n\ttype PartialSuccessReport,\n\tpost,\n\ttype Transport,\n\twait,\n} from './transport';\n\nexport const DEFAULT_LOGS_PATH = '/v1/logs';\nexport const DEFAULT_TRACES_PATH = '/v1/traces';\n\nexport interface OtlpExporterOptions {\n\t/** The collector's base URL, e.g. `http://localhost:4318`. */\n\treadonly endpoint: string;\n\t/** Appended to `endpoint`. Default `/v1/logs`. */\n\treadonly logsPath?: string;\n\t/** Appended to `endpoint`. Default `/v1/traces`. */\n\treadonly tracesPath?: string;\n\t/** Sent on every request — an API key, a tenant. */\n\treadonly headers?: Readonly<Record<string, string>>;\n\t/** Per attempt, in milliseconds. Default 10s. */\n\treadonly timeout?: number;\n\t/** How many requests one document gets. Default 3. `1` disables retrying. */\n\treadonly attempts?: number;\n\t/** The first wait between attempts, doubled each time. Default 500ms. */\n\treadonly backoff?: number;\n\t/** Gzip a document over 1 KiB. Default true. */\n\treadonly gzip?: boolean;\n\t/** Called when the collector accepted the request but not every record. */\n\treadonly onPartialSuccess?: (report: PartialSuccessReport) => void;\n\t/** For specs, and for an application that routes its own traffic. */\n\treadonly fetch?: typeof fetch;\n\t/** For specs. Default a `setTimeout` that does not hold the process open. */\n\treadonly sleep?: (ms: number) => Promise<void>;\n}\n\n/**\n * Logs and traces to an OpenTelemetry collector, as OTLP/HTTP JSON.\n *\n * ```ts\n * otlpExporter({ endpoint: 'http://localhost:4318', headers: { 'x-api-key': key } })\n * ```\n *\n * There is no OpenTelemetry SDK behind this. OTLP is a wire format, and the\n * whole of it that a collector needs is two JSON documents over `fetch` — which\n * is what lets this package have one dependency and no transitive tree, and\n * what keeps the context in `AsyncLocalStorage` instead of in the Java SDK's\n * thread-local shape.\n *\n * A mixed batch is **two requests**, one per document, sent together: logs\n * failing must not cost the traces of the same batch. Both are attempted, and\n * both failures are thrown — as an `AggregateError` when there are two —\n * which means they reach `onExportError` and nothing else.\n */\nexport function otlpExporter(options: OtlpExporterOptions): Exporter {\n\tconst endpoint = options.endpoint.replace(/\\/+$/, '');\n\tconst logsUrl = endpoint + (options.logsPath ?? DEFAULT_LOGS_PATH);\n\tconst tracesUrl = endpoint + (options.tracesPath ?? DEFAULT_TRACES_PATH);\n\n\tconst transport: Transport = {\n\t\theaders: options.headers ?? {},\n\t\ttimeout: options.timeout ?? DEFAULT_TIMEOUT,\n\t\tattempts: Math.max(options.attempts ?? DEFAULT_ATTEMPTS, 1),\n\t\tbackoff: options.backoff ?? DEFAULT_BACKOFF,\n\t\tgzip: options.gzip ?? true,\n\t\t...(options.onPartialSuccess === undefined\n\t\t\t? {}\n\t\t\t: { onPartialSuccess: options.onPartialSuccess }),\n\t\tfetch: options.fetch ?? fetch,\n\t\tsleep: options.sleep ?? wait,\n\t};\n\n\treturn {\n\t\tasync export(resource: Resource, batch: readonly Signal[]): Promise<void> {\n\t\t\tconst logs = logsRequest(resource, batch);\n\t\t\tconst traces = tracesRequest(resource, batch);\n\n\t\t\tconst sent = await Promise.allSettled([\n\t\t\t\tlogs === undefined ? undefined : post(transport, logsUrl, 'logs', logs),\n\t\t\t\ttraces === undefined\n\t\t\t\t\t? undefined\n\t\t\t\t\t: post(transport, tracesUrl, 'traces', traces),\n\t\t\t]);\n\n\t\t\tthrowAll(sent);\n\t\t},\n\t};\n}\n\n/**\n * The two documents go to two paths and can fail for unrelated reasons — a\n * `400` on one and a refused connection on the other is not one outage. Losing\n * the second is losing the half of the story that says the collector is not\n * simply down, so both are reported.\n */\nfunction throwAll(sent: readonly PromiseSettledResult<unknown>[]): void {\n\tconst failures = sent\n\t\t.filter((one) => one.status === 'rejected')\n\t\t.map((one) => one.reason as unknown);\n\n\tif (failures.length === 0) return;\n\tif (failures.length === 1) throw failures[0];\n\n\tthrow new AggregateError(\n\t\tfailures,\n\t\t'[telemetry] neither the logs nor the traces of this batch arrived',\n\t);\n}\n"
|
|
9
|
+
],
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "4685C21DF9219CEA64756E2164756E21",
|
|
12
|
+
"names": []
|
|
13
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Attributes, AttributeValue, Resource, Signal, SpanStatus } from '@nxgt/telemetry';
|
|
2
|
+
import type { AnyValue, ExportLogsServiceRequest, ExportTraceServiceRequest, KeyValue, OtlpResource } from './documents';
|
|
3
|
+
/** The instrumentation scope reported for spans. Logs report their `source`. */
|
|
4
|
+
export declare const SPAN_SCOPE = "nxgt-telemetry";
|
|
5
|
+
/**
|
|
6
|
+
* `ok` maps to `1` and `error` to `2`, and **`cancelled` maps to `0`** — unset,
|
|
7
|
+
* not error. A shutdown and a timeout are not failures, and a dashboard that
|
|
8
|
+
* counts them as such is a dashboard nobody trusts; OTLP has no third code, so
|
|
9
|
+
* the honest answer is to say nothing rather than to say "failed".
|
|
10
|
+
*/
|
|
11
|
+
export declare const STATUS_CODE: Readonly<Record<SpanStatus, number>>;
|
|
12
|
+
/**
|
|
13
|
+
* Epoch milliseconds to the decimal nanoseconds OTLP wants, through `BigInt`.
|
|
14
|
+
*
|
|
15
|
+
* A `number` would be wrong from 2001 onwards: `Date.now() * 1e6` is well past
|
|
16
|
+
* `Number.MAX_SAFE_INTEGER`, so the last digits are whatever the float rounds
|
|
17
|
+
* to, and two signals a microsecond apart arrive with the same timestamp.
|
|
18
|
+
*/
|
|
19
|
+
export declare function nanos(at: number): string;
|
|
20
|
+
/** A scalar, tagged the way OTLP tags it. */
|
|
21
|
+
export declare function anyValue(value: AttributeValue): AnyValue;
|
|
22
|
+
export declare function keyValues(attributes: Attributes): KeyValue[];
|
|
23
|
+
/**
|
|
24
|
+
* The resource, with `service.name`, `service.version` and
|
|
25
|
+
* `deployment.environment.name` — the three keys every backend looks for, under
|
|
26
|
+
* the names the semantic conventions give them. An attribute already carrying
|
|
27
|
+
* one of those keys wins: it was set deliberately.
|
|
28
|
+
*/
|
|
29
|
+
export declare function otlpResource(resource: Resource): OtlpResource;
|
|
30
|
+
/**
|
|
31
|
+
* The logs of a batch, **grouped by `source`** into one scope each: the logger's
|
|
32
|
+
* name is the instrumentation scope, which is what lets a backend filter by the
|
|
33
|
+
* component that wrote the line rather than by the service that ran it.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` when there is nothing to send — an empty document is a request
|
|
36
|
+
* worth not making.
|
|
37
|
+
*/
|
|
38
|
+
export declare function logsRequest(resource: Resource, batch: readonly Signal[]): ExportLogsServiceRequest | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The spans of a batch, in one scope named for this library. A span has no
|
|
41
|
+
* `source` — it is named for the work, not for the component — so there is
|
|
42
|
+
* nothing to group by.
|
|
43
|
+
*/
|
|
44
|
+
export declare function tracesRequest(resource: Resource, batch: readonly Signal[]): ExportTraceServiceRequest | undefined;
|
|
45
|
+
//# sourceMappingURL=convert.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"convert.d.ts","sourceRoot":"","sources":["../../src/wire/convert.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,UAAU,EACV,cAAc,EAGd,QAAQ,EACR,MAAM,EAEN,UAAU,EACV,MAAM,iBAAiB,CAAC;AAEzB,OAAO,KAAK,EACX,QAAQ,EACR,wBAAwB,EACxB,yBAAyB,EACzB,QAAQ,EAER,YAAY,EAIZ,MAAM,aAAa,CAAC;AAErB,gFAAgF;AAChF,eAAO,MAAM,UAAU,mBAAmB,CAAC;AAE3C;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAI3D,CAAC;AAEH;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAExC;AAED,6CAA6C;AAC7C,wBAAgB,QAAQ,CAAC,KAAK,EAAE,cAAc,GAAG,QAAQ,CAuBxD;AAED,wBAAgB,SAAS,CAAC,UAAU,EAAE,UAAU,GAAG,QAAQ,EAAE,CAK5D;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,YAAY,CAY7D;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAC1B,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,SAAS,MAAM,EAAE,GACtB,wBAAwB,GAAG,SAAS,CAkBtC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC5B,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,SAAS,MAAM,EAAE,GACtB,yBAAyB,GAAG,SAAS,CAevC"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OTLP/HTTP **JSON** documents, as types.
|
|
3
|
+
*
|
|
4
|
+
* These are hand-written rather than generated from the protobufs, and that is
|
|
5
|
+
* the point of this package: OTLP is a wire format, not a library. The whole
|
|
6
|
+
* surface a collector needs is the two request documents below, and writing
|
|
7
|
+
* them by hand costs less than the SDK's dependency tree.
|
|
8
|
+
*
|
|
9
|
+
* Two places where proto3 JSON and OTLP JSON differ, and where an
|
|
10
|
+
* implementation that guesses gets it wrong:
|
|
11
|
+
*
|
|
12
|
+
* - **ids are lowercase hex, not base64.** Proto3 JSON would encode a `bytes`
|
|
13
|
+
* field as base64; the OTLP specification overrides that for `trace_id`,
|
|
14
|
+
* `span_id` and `parent_span_id`.
|
|
15
|
+
* - **64-bit integers are strings.** `timeUnixNano` and an integer attribute
|
|
16
|
+
* both cross the wire as text, because a JSON number is a double and
|
|
17
|
+
* nanoseconds do not fit in one.
|
|
18
|
+
*/
|
|
19
|
+
/** A value, tagged by which of the fields is set. An empty object is unset. */
|
|
20
|
+
export interface AnyValue {
|
|
21
|
+
readonly stringValue?: string;
|
|
22
|
+
readonly boolValue?: boolean;
|
|
23
|
+
/** Decimal text: a JSON number would lose the low bits. */
|
|
24
|
+
readonly intValue?: string;
|
|
25
|
+
readonly doubleValue?: number;
|
|
26
|
+
readonly arrayValue?: {
|
|
27
|
+
readonly values: readonly AnyValue[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface KeyValue {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly value: AnyValue;
|
|
33
|
+
}
|
|
34
|
+
export interface OtlpResource {
|
|
35
|
+
readonly attributes: readonly KeyValue[];
|
|
36
|
+
}
|
|
37
|
+
/** Which library produced the signal. `source` for logs, this package for spans. */
|
|
38
|
+
export interface InstrumentationScope {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly version?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface OtlpLogRecord {
|
|
43
|
+
readonly timeUnixNano: string;
|
|
44
|
+
readonly observedTimeUnixNano: string;
|
|
45
|
+
readonly severityNumber: number;
|
|
46
|
+
readonly severityText: string;
|
|
47
|
+
readonly body: AnyValue;
|
|
48
|
+
readonly attributes: readonly KeyValue[];
|
|
49
|
+
readonly traceId?: string;
|
|
50
|
+
readonly spanId?: string;
|
|
51
|
+
/** The W3C trace flags, in the low eight bits: `1` when sampled. */
|
|
52
|
+
readonly flags?: number;
|
|
53
|
+
}
|
|
54
|
+
export interface ScopeLogs {
|
|
55
|
+
readonly scope: InstrumentationScope;
|
|
56
|
+
readonly logRecords: readonly OtlpLogRecord[];
|
|
57
|
+
}
|
|
58
|
+
export interface ResourceLogs {
|
|
59
|
+
readonly resource: OtlpResource;
|
|
60
|
+
readonly scopeLogs: readonly ScopeLogs[];
|
|
61
|
+
}
|
|
62
|
+
export interface ExportLogsServiceRequest {
|
|
63
|
+
readonly resourceLogs: readonly ResourceLogs[];
|
|
64
|
+
}
|
|
65
|
+
export interface OtlpSpanEvent {
|
|
66
|
+
readonly timeUnixNano: string;
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly attributes: readonly KeyValue[];
|
|
69
|
+
}
|
|
70
|
+
export interface OtlpStatus {
|
|
71
|
+
/** `0` unset, `1` ok, `2` error. */
|
|
72
|
+
readonly code: number;
|
|
73
|
+
readonly message?: string;
|
|
74
|
+
}
|
|
75
|
+
export interface OtlpSpan {
|
|
76
|
+
readonly traceId: string;
|
|
77
|
+
readonly spanId: string;
|
|
78
|
+
readonly parentSpanId?: string;
|
|
79
|
+
readonly name: string;
|
|
80
|
+
/** `1` internal … `5` consumer. */
|
|
81
|
+
readonly kind: number;
|
|
82
|
+
readonly startTimeUnixNano: string;
|
|
83
|
+
readonly endTimeUnixNano: string;
|
|
84
|
+
readonly attributes: readonly KeyValue[];
|
|
85
|
+
readonly events: readonly OtlpSpanEvent[];
|
|
86
|
+
readonly status: OtlpStatus;
|
|
87
|
+
}
|
|
88
|
+
export interface ScopeSpans {
|
|
89
|
+
readonly scope: InstrumentationScope;
|
|
90
|
+
readonly spans: readonly OtlpSpan[];
|
|
91
|
+
}
|
|
92
|
+
export interface ResourceSpans {
|
|
93
|
+
readonly resource: OtlpResource;
|
|
94
|
+
readonly scopeSpans: readonly ScopeSpans[];
|
|
95
|
+
}
|
|
96
|
+
export interface ExportTraceServiceRequest {
|
|
97
|
+
readonly resourceSpans: readonly ResourceSpans[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* What a collector answers when it accepted the request but not everything in
|
|
101
|
+
* it — a rejected record, a quota, a bad attribute. The counts are strings for
|
|
102
|
+
* the same reason `timeUnixNano` is.
|
|
103
|
+
*/
|
|
104
|
+
export interface PartialSuccess {
|
|
105
|
+
readonly rejectedLogRecords?: string;
|
|
106
|
+
readonly rejectedSpans?: string;
|
|
107
|
+
readonly errorMessage?: string;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=documents.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../../src/wire/documents.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nxgt/telemetry-otlp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "An OTLP/HTTP JSON exporter for @nxgt/telemetry: logs and traces to any OpenTelemetry collector, over fetch, with no OpenTelemetry SDK",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"package.json",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"otlp",
|
|
25
|
+
"opentelemetry",
|
|
26
|
+
"collector",
|
|
27
|
+
"telemetry",
|
|
28
|
+
"observability",
|
|
29
|
+
"tracing",
|
|
30
|
+
"logging",
|
|
31
|
+
"typescript"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/softistx/nxgt-telemetry.git",
|
|
36
|
+
"directory": "packages/telemetry-otlp"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"registry": "https://registry.npmjs.org",
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun run ../../build.ts",
|
|
44
|
+
"test": "bun test src",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"nxgt": {
|
|
48
|
+
"entrypoints": [
|
|
49
|
+
"src/index.ts"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@nxgt/telemetry": "^0.1.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/bun": "^1.4.2"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"typescript": "^6.0.3"
|
|
60
|
+
}
|
|
61
|
+
}
|