@docx-editor.dev/telemetry 0.0.1 → 2.30.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/README.md +89 -7
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/dist/chunk-6BTB6Y3H.cjs +1 -0
- package/dist/chunk-7SYLCJFQ.cjs +1 -0
- package/dist/chunk-AL2YERWY.cjs +1 -0
- package/dist/chunk-DT2KMPUJ.js +1 -0
- package/dist/chunk-E2HR5DHZ.cjs +2 -0
- package/dist/chunk-E3FTXDLH.js +1 -0
- package/dist/chunk-JM4KJB6O.js +4 -0
- package/dist/chunk-PZZC5AXX.cjs +1 -0
- package/dist/chunk-QDOUVRQA.cjs +4 -0
- package/dist/chunk-RSWAOCAT.js +1 -0
- package/dist/chunk-W5HFB5EV.js +1 -0
- package/dist/chunk-WGWKNKUT.cjs +1 -0
- package/dist/chunk-WHVIRIUW.js +1 -0
- package/dist/chunk-WLWS5CZT.js +2 -0
- package/dist/hocuspocus/index.cjs +1 -0
- package/dist/hocuspocus/index.d.cts +118 -0
- package/dist/hocuspocus/index.d.ts +118 -0
- package/dist/hocuspocus/index.js +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +355 -0
- package/dist/index.d.ts +355 -0
- package/dist/index.js +1 -0
- package/dist/ingest/index.cjs +1 -0
- package/dist/ingest/index.d.cts +116 -0
- package/dist/ingest/index.d.ts +116 -0
- package/dist/ingest/index.js +1 -0
- package/dist/otel/index.cjs +1 -0
- package/dist/otel/index.d.cts +57 -0
- package/dist/otel/index.d.ts +57 -0
- package/dist/otel/index.js +1 -0
- package/dist/pipeline-CCq2Ng0B.d.cts +229 -0
- package/dist/pipeline-CCq2Ng0B.d.ts +229 -0
- package/dist/prometheus/index.cjs +1 -0
- package/dist/prometheus/index.d.cts +88 -0
- package/dist/prometheus/index.d.ts +88 -0
- package/dist/prometheus/index.js +1 -0
- package/dist/sentry/index.cjs +1 -0
- package/dist/sentry/index.d.cts +134 -0
- package/dist/sentry/index.d.ts +134 -0
- package/dist/sentry/index.js +1 -0
- package/dist/server/index.cjs +1 -0
- package/dist/server/index.d.cts +156 -0
- package/dist/server/index.d.ts +156 -0
- package/dist/server/index.js +1 -0
- package/package.json +100 -16
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous diagnostics, metrics, and error reporting for editors and collaboration servers.
|
|
3
|
+
*
|
|
4
|
+
* - `@docx-editor.dev/telemetry`: {@link createTelemetry} for editors, the console, callback,
|
|
5
|
+
* memory, and HTTP exporters, and {@link browserPrivacySignals}.
|
|
6
|
+
* - `@docx-editor.dev/telemetry/ingest`: a route that receives telemetry from `httpExporter`
|
|
7
|
+
* and forwards it to a log service.
|
|
8
|
+
* - `@docx-editor.dev/telemetry/otel`: OpenTelemetry metrics and traces through your SDK.
|
|
9
|
+
* - `@docx-editor.dev/telemetry/sentry`: Sentry issues and breadcrumbs.
|
|
10
|
+
* - `@docx-editor.dev/telemetry/prometheus`: a `/metrics` endpoint.
|
|
11
|
+
* - `@docx-editor.dev/telemetry/server`: room telemetry for any Yjs server.
|
|
12
|
+
* - `@docx-editor.dev/telemetry/hocuspocus`: room telemetry for Hocuspocus in one call.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
import { EditorModule, EditorDiagnostic } from '@docx-editor.dev/core';
|
|
17
|
+
export { ByteSizeBucket, CollaborationDiagnosticCode, CollaborationParticipantsDiagnostic, CollaborationStatusDiagnostic, CountBucket, CustomNodeDiagnosticCode, CustomNodeDiagnosticEvent, DiagnosticBase, DiagnosticCode, DiagnosticErrorName, DiagnosticExplanation, DiagnosticFailureDetail, DocumentOpenDiagnostic, DocumentOpenDiagnosticCode, DocumentPartKind, DocumentSaveDiagnostic, DocumentSaveDiagnosticCode, DocumentShape, EditDiagnosticCode, EditRejectedDiagnostic, EditRejectionOrigin, EditorDiagnostic, EditorDiagnosticKind, FailureStage, FontDiagnosticCode, FontFailedDiagnostic, FormattingChangeKind, HistoryDiagnosticCode, HistoryDiagnosticEvent, PerformanceDiagnostic, PerformanceDiagnosticCode, ReviewChangeKind, ReviewDiagnosticCode, ReviewOperation, ReviewSkippedDiagnostic, SessionActivity, SessionDiagnostic, TimingSummary, explainDiagnostic } from '@docx-editor.dev/core';
|
|
18
|
+
import { A as Attributes, T as TelemetryPipelineOptions, a as TelemetryEvent, b as TelemetryExporter, M as MetricRecord } from './pipeline-CCq2Ng0B.js';
|
|
19
|
+
export { c as AttributeValue, E as ENGINE_VERSION, d as MAX_METRIC_LABEL_VALUES, e as METRICS, f as MetricDefinition, g as MetricId, h as MetricKind, i as TelemetryEventContext, j as TelemetryExporterContext, p as prometheusName } from './pipeline-CCq2Ng0B.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The areas you can switch on. Each one selects diagnostic kinds:
|
|
23
|
+
*
|
|
24
|
+
* - `documentOpen`: documents opened and refused (`document.open`).
|
|
25
|
+
* - `editing`: edits and commands refused while changes are not tracked (`edit.rejected`).
|
|
26
|
+
* - `trackedChanges`: refusals while suggesting, refusals about tracking itself, and skipped
|
|
27
|
+
* tracked changes
|
|
28
|
+
* (`edit.rejected` with `tracked: true`, `review.skipped`).
|
|
29
|
+
* - `fonts`: fonts that failed and were substituted (`font.failed`).
|
|
30
|
+
* - `history`: undo steps that did not group as the gesture intended (`history`).
|
|
31
|
+
* - `customNodes`: custom node payloads that could not be read (`custom-node`).
|
|
32
|
+
* - `collaborationClient`: collaboration status changes and room size (`collab.*`).
|
|
33
|
+
* - `performance`: how fast the editor responds: key presses and clicks to the next frame,
|
|
34
|
+
* and engine layout and paint times, as one summary per minute of use (`performance`).
|
|
35
|
+
* - `documentSave`: calls to `save()` that returned the document or failed (`document.save`).
|
|
36
|
+
* - `sessions`: when each editor starts and ends, how long it is used, and how many edits it
|
|
37
|
+
* commits, as one summary per minute of use (`session`).
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
type TelemetryArea = 'documentOpen' | 'documentSave' | 'sessions' | 'editing' | 'trackedChanges' | 'fonts' | 'history' | 'customNodes' | 'collaborationClient' | 'performance';
|
|
42
|
+
/** Options for one area. @public */
|
|
43
|
+
interface TelemetryAreaOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Share of events to send, from 0 to 1. Metrics count every event, except that a
|
|
46
|
+
* `performance` rate of 0 turns performance off, metrics included. Defaults to 1.
|
|
47
|
+
* Each event is sampled on its own, except in `performance` and `sessions`: there the choice
|
|
48
|
+
* is made once for each editor, which sends all of its summaries or none.
|
|
49
|
+
*/
|
|
50
|
+
readonly sampleRate?: number;
|
|
51
|
+
}
|
|
52
|
+
/** Which areas to record. An area that is absent or `false` records nothing. @public */
|
|
53
|
+
type TelemetryInclude = {
|
|
54
|
+
readonly [A in TelemetryArea]?: boolean | TelemetryAreaOptions;
|
|
55
|
+
};
|
|
56
|
+
/** Options for {@link createTelemetry}. @public */
|
|
57
|
+
interface TelemetryOptions extends TelemetryPipelineOptions {
|
|
58
|
+
/**
|
|
59
|
+
* The areas to record. Omitted, every area is on.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* include: { documentOpen: true, trackedChanges: true, editing: { sampleRate: 0.2 } }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
readonly include?: TelemetryInclude;
|
|
67
|
+
/**
|
|
68
|
+
* Identical refusals inside this window are sent once, then summarized with `repeated`.
|
|
69
|
+
* Holding Backspace on a locked drawing is one event, not hundreds. Defaults to 10 000 ms.
|
|
70
|
+
* Set 0 to send every event.
|
|
71
|
+
*/
|
|
72
|
+
readonly dedupeWindowMs?: number;
|
|
73
|
+
/**
|
|
74
|
+
* How much of an opened document's shape to send. `'basic'`, the default, sends the size and
|
|
75
|
+
* page buckets. `'detailed'` also sends part, paragraph, review item, and embedded font
|
|
76
|
+
* buckets: useful to group open times, but several counts together describe a document more
|
|
77
|
+
* closely, so ask for it only when you need it.
|
|
78
|
+
*/
|
|
79
|
+
readonly documentShape?: 'basic' | 'detailed';
|
|
80
|
+
/**
|
|
81
|
+
* Your identifiers on every event, such as a tenant or a pseudonymous user id. A function is
|
|
82
|
+
* read at each event, so it can follow sign-in and tenant switches. Events only: to label
|
|
83
|
+
* metrics with a key, list it in `metricLabels`. For values that belong to one editor, such as
|
|
84
|
+
* its room or document, use {@link Telemetry.moduleFor}.
|
|
85
|
+
*
|
|
86
|
+
* These values are yours and go only to your exporters. Keys follow attribute rules: letters,
|
|
87
|
+
* digits, `_`, `.`, and `-`, up to 64 characters. Values are strings, numbers, or booleans;
|
|
88
|
+
* strings are cut at 256 characters.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* attributes: () => ({ 'tenant.id': session.tenantId, 'user.id': session.pseudonymousId })
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
readonly attributes?: Attributes | (() => Attributes);
|
|
96
|
+
/**
|
|
97
|
+
* Which events go to your exporters, after the privacy check and sampling. Metrics still count
|
|
98
|
+
* every event. A filter that throws drops the event.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* // Failures and performance summaries only: no event for an open that succeeded.
|
|
103
|
+
* filter: (event) => isFailure(event) || event.kind === 'performance'
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
readonly filter?: (event: TelemetryEvent) => boolean;
|
|
107
|
+
}
|
|
108
|
+
/** A configured telemetry client. Create it once, outside any component. @public */
|
|
109
|
+
interface Telemetry {
|
|
110
|
+
/**
|
|
111
|
+
* Register this in the editor's `modules`. It observes every editor created with it, from
|
|
112
|
+
* the first open on.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```tsx
|
|
116
|
+
* const MODULES = [reviewModule(), telemetry.module];
|
|
117
|
+
* <DocxEditor document={bytes} modules={MODULES} />;
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
readonly module: EditorModule;
|
|
121
|
+
/**
|
|
122
|
+
* A module for one editor, whose events also carry `attributes`, such as its collaboration
|
|
123
|
+
* room, document, or workspace. Create one for each editor, and keep it stable across renders.
|
|
124
|
+
* Use it in place of `module`, not next to it.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```tsx
|
|
128
|
+
* const modules = useMemo(
|
|
129
|
+
* () => [reviewModule(), telemetry.moduleFor({ 'room.id': roomId, 'document.id': documentId })],
|
|
130
|
+
* [roomId, documentId]
|
|
131
|
+
* );
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
moduleFor(attributes: Attributes): EditorModule;
|
|
135
|
+
/**
|
|
136
|
+
* Record one diagnostic yourself, for example from a runtime you created without `module`.
|
|
137
|
+
* Use `module` or `record`, not both, or every event is recorded twice. `attributes` are added
|
|
138
|
+
* to this event, as `moduleFor` adds them.
|
|
139
|
+
*/
|
|
140
|
+
record(event: EditorDiagnostic, attributes?: Attributes): void;
|
|
141
|
+
/** Send summaries of open deduplication windows, then flush every exporter. */
|
|
142
|
+
flush(): Promise<void>;
|
|
143
|
+
/** Flush, then stop. Later events are dropped. */
|
|
144
|
+
shutdown(): Promise<void>;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a telemetry client for editors.
|
|
148
|
+
*
|
|
149
|
+
* Nothing is sent unless you pass an exporter. With `consent`, nothing is sent while it is
|
|
150
|
+
* false; without it, events are sent. Every event is anonymous: codes, enums, and bucketed
|
|
151
|
+
* numbers only.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* import { createTelemetry, consoleExporter } from '@docx-editor.dev/telemetry';
|
|
156
|
+
*
|
|
157
|
+
* export const telemetry = createTelemetry({
|
|
158
|
+
* exporters: [consoleExporter()],
|
|
159
|
+
* include: { documentOpen: true, trackedChanges: true },
|
|
160
|
+
* });
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* @public
|
|
164
|
+
*/
|
|
165
|
+
declare function createTelemetry(options: TelemetryOptions): Telemetry;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sending telemetry to your own HTTP endpoint from a browser, and reading the browser's privacy
|
|
169
|
+
* signals. No dependency: `fetch`, `sendBeacon`, and `localStorage` only.
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
/** Options for {@link httpExporter}. @public */
|
|
173
|
+
interface HttpExporterOptions {
|
|
174
|
+
/**
|
|
175
|
+
* Where to POST. Use a route on your own origin, which then forwards to your log service, so
|
|
176
|
+
* the service's token never reaches the browser. See `@docx-editor.dev/telemetry/ingest`.
|
|
177
|
+
*/
|
|
178
|
+
readonly url: string;
|
|
179
|
+
/**
|
|
180
|
+
* Events per request, from 1 to 50. Defaults to 20. A full batch is sent at once. The limit
|
|
181
|
+
* matches the default `maxEvents` of `telemetryRoute`, which refuses a longer list.
|
|
182
|
+
*/
|
|
183
|
+
readonly batchSize?: number;
|
|
184
|
+
/** How long an event waits for its batch to fill, in milliseconds. Defaults to 10 000. */
|
|
185
|
+
readonly flushIntervalMs?: number;
|
|
186
|
+
/**
|
|
187
|
+
* Extra request headers. A browser cannot set headers on `sendBeacon`, so with headers, the
|
|
188
|
+
* batches sent while the page closes use `fetch` with `keepalive` instead.
|
|
189
|
+
*/
|
|
190
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
191
|
+
/**
|
|
192
|
+
* Called when a request fails, or when batches are dropped because the page closed with more
|
|
193
|
+
* than the browser lets it send. Telemetry never throws into your app.
|
|
194
|
+
*/
|
|
195
|
+
readonly onError?: (error: unknown) => void;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Batch events and POST them as a JSON array to your own endpoint.
|
|
199
|
+
*
|
|
200
|
+
* While the page is open, events wait up to `flushIntervalMs` for a batch of `batchSize`, and
|
|
201
|
+
* each batch goes out with an ordinary `fetch`; so does a `telemetry.flush()` you call. When the
|
|
202
|
+
* page is hidden or closes, everything waiting goes out with `sendBeacon`. A closing page may
|
|
203
|
+
* send only about 64 KiB in all, so delivery while closing is best effort: batches beyond that
|
|
204
|
+
* are dropped and reported to `onError`. If consent is withdrawn, events still waiting are
|
|
205
|
+
* discarded.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* import { createTelemetry, httpExporter } from '@docx-editor.dev/telemetry';
|
|
210
|
+
*
|
|
211
|
+
* export const telemetry = createTelemetry({
|
|
212
|
+
* exporters: [httpExporter({ url: '/api/telemetry' })],
|
|
213
|
+
* include: { documentOpen: true, editing: true, trackedChanges: true, performance: true },
|
|
214
|
+
* });
|
|
215
|
+
* ```
|
|
216
|
+
*
|
|
217
|
+
* @public
|
|
218
|
+
*/
|
|
219
|
+
declare function httpExporter(options: HttpExporterOptions): TelemetryExporter;
|
|
220
|
+
/** Options for {@link browserPrivacySignals}. @public */
|
|
221
|
+
interface BrowserPrivacySignalsOptions {
|
|
222
|
+
/**
|
|
223
|
+
* A `localStorage` key that opts the visitor out when its value is `off`, such as one your
|
|
224
|
+
* privacy settings page writes. Omit it to read only the browser signals.
|
|
225
|
+
*/
|
|
226
|
+
readonly optOutKey?: string;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* A `consent` function that follows the browser's privacy signals: it returns `false` when the
|
|
230
|
+
* visitor sends Global Privacy Control or Do Not Track, or when `optOutKey` is set to `off`.
|
|
231
|
+
* Outside a browser page, such as during server rendering, it returns `false`. Combine it with
|
|
232
|
+
* your consent manager when you have one.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```ts
|
|
236
|
+
* import { browserPrivacySignals, createTelemetry } from '@docx-editor.dev/telemetry';
|
|
237
|
+
*
|
|
238
|
+
* const privacy = browserPrivacySignals({ optOutKey: 'my-app:telemetry' });
|
|
239
|
+
* export const telemetry = createTelemetry({
|
|
240
|
+
* exporters: [],
|
|
241
|
+
* consent: () => privacy() && consentManager.analytics,
|
|
242
|
+
* });
|
|
243
|
+
* ```
|
|
244
|
+
*
|
|
245
|
+
* @public
|
|
246
|
+
*/
|
|
247
|
+
declare function browserPrivacySignals(options?: BrowserPrivacySignalsOptions): () => boolean;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Send each event to your own function: Datadog RUM, PostHog, a logger, or an API route.
|
|
251
|
+
*
|
|
252
|
+
* `onMetric` receives every measurement. A performance summary arrives as one histogram sample
|
|
253
|
+
* for each timing in it, at the middle of the timing's band, so the bucket counts are exact.
|
|
254
|
+
* To chart percentiles, read `p50Ms` and `p95Ms` from the `performance` event instead.
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```ts
|
|
258
|
+
* callbackExporter((event) =>
|
|
259
|
+
* isFailure(event)
|
|
260
|
+
* ? datadogRum.addError(new Error(event.kind), event)
|
|
261
|
+
* : datadogRum.addAction(event.kind, event)
|
|
262
|
+
* );
|
|
263
|
+
* ```
|
|
264
|
+
*
|
|
265
|
+
* @public
|
|
266
|
+
*/
|
|
267
|
+
declare function callbackExporter(onEvent: (event: TelemetryEvent) => void, onMetric?: (record: MetricRecord) => void): TelemetryExporter;
|
|
268
|
+
/** Options for {@link consoleExporter}. @public */
|
|
269
|
+
interface ConsoleExporterOptions {
|
|
270
|
+
/** Also print every metric measurement. Defaults to `false`. */
|
|
271
|
+
readonly metrics?: boolean;
|
|
272
|
+
/** Where to print. Defaults to the global `console`. */
|
|
273
|
+
readonly console?: Pick<Console, 'info' | 'warn'>;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Whether an event is a failure: a refused open, a refused edit other than an expected one
|
|
277
|
+
* (a stale range, a read-only document, protected content, a session or refresh not ready
|
|
278
|
+
* yet), an unreadable payload, a font that did not load, or a collaboration error. A missing
|
|
279
|
+
* font that was substituted and a tracked change someone else already reviewed are not. Use it
|
|
280
|
+
* in a callback to send failures as errors and the rest as actions or logs.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* callbackExporter((event) => {
|
|
285
|
+
* const code = 'code' in event ? event.code : undefined;
|
|
286
|
+
* if (isFailure(event)) myLogger.error(event.kind, { code, hint: event.hint });
|
|
287
|
+
* else myLogger.info(event.kind);
|
|
288
|
+
* });
|
|
289
|
+
* ```
|
|
290
|
+
*
|
|
291
|
+
* A deduplication summary returns `false`: it repeats an event already reported. To total
|
|
292
|
+
* failures with summaries included, use {@link failureCount}.
|
|
293
|
+
*
|
|
294
|
+
* @public
|
|
295
|
+
*/
|
|
296
|
+
declare function isFailure(event: TelemetryEvent): boolean;
|
|
297
|
+
/**
|
|
298
|
+
* How many failures an event adds to a failure total: `0` when it is not a failure, `1` for a
|
|
299
|
+
* failure, and `repeated` for a deduplication summary of one. The sum over every event a
|
|
300
|
+
* client sends equals the number of failures the editor reported, which {@link isFailure}
|
|
301
|
+
* alone undercounts. Sampling still applies: a sampled area sends a share of its events.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* let failures = 0;
|
|
306
|
+
* callbackExporter((event) => {
|
|
307
|
+
* failures += failureCount(event);
|
|
308
|
+
* });
|
|
309
|
+
* ```
|
|
310
|
+
*
|
|
311
|
+
* @public
|
|
312
|
+
*/
|
|
313
|
+
declare function failureCount(event: TelemetryEvent): number;
|
|
314
|
+
/**
|
|
315
|
+
* Print each event with its hint and docs link. Use it during development to see why a
|
|
316
|
+
* document did not open or why a tracked change was refused.
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```ts
|
|
320
|
+
* createTelemetry({ exporters: [consoleExporter()] });
|
|
321
|
+
* // An agent deletes a picture while changes are tracked:
|
|
322
|
+
* // [docx-editor] edit refused: edit.tracked-operation-unsupported (automation, tracked)
|
|
323
|
+
* // This editing API operation cannot be recorded as a tracked change, so nothing was ...
|
|
324
|
+
* // https://docx-editor.dev/docs/2.x/guides/diagnostics#edit-tracked-operation-unsupported
|
|
325
|
+
* ```
|
|
326
|
+
*
|
|
327
|
+
* @public
|
|
328
|
+
*/
|
|
329
|
+
declare function consoleExporter(options?: ConsoleExporterOptions): TelemetryExporter;
|
|
330
|
+
/** An exporter that keeps what it receives. Returned by {@link memoryExporter}. @public */
|
|
331
|
+
interface MemoryExporter extends TelemetryExporter {
|
|
332
|
+
readonly events: readonly TelemetryEvent[];
|
|
333
|
+
readonly metrics: readonly MetricRecord[];
|
|
334
|
+
/** Forget everything received so far. */
|
|
335
|
+
clear(): void;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Keep events and metrics in memory, for tests in your own suite.
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* ```ts
|
|
342
|
+
* const memory = memoryExporter();
|
|
343
|
+
* const telemetry = createTelemetry({ exporters: [memory] });
|
|
344
|
+
* // ...open a file that fails...
|
|
345
|
+
* expect(memory.events).toContainEqual(
|
|
346
|
+
* expect.objectContaining({ code: 'open.missing-main-document' })
|
|
347
|
+
* );
|
|
348
|
+
* ```
|
|
349
|
+
*
|
|
350
|
+
* @public
|
|
351
|
+
*/
|
|
352
|
+
declare function memoryExporter(): MemoryExporter;
|
|
353
|
+
|
|
354
|
+
export { Attributes, MetricRecord, TelemetryEvent, TelemetryExporter, TelemetryPipelineOptions, browserPrivacySignals, callbackExporter, consoleExporter, createTelemetry, failureCount, httpExporter, isFailure, memoryExporter };
|
|
355
|
+
export type { BrowserPrivacySignalsOptions, ConsoleExporterOptions, HttpExporterOptions, MemoryExporter, Telemetry, TelemetryArea, TelemetryAreaOptions, TelemetryInclude, TelemetryOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import {c,b as b$1}from'./chunk-WHVIRIUW.js';export{b as ENGINE_VERSION,a as MAX_METRIC_LABEL_VALUES}from'./chunk-WHVIRIUW.js';import {a as a$1}from'./chunk-RSWAOCAT.js';export{a as METRICS,b as prometheusName}from'./chunk-RSWAOCAT.js';export{a as callbackExporter,e as consoleExporter,d as failureCount,c as isFailure,f as memoryExporter}from'./chunk-WLWS5CZT.js';import {c as c$1,a,b}from'./chunk-W5HFB5EV.js';import'./chunk-DT2KMPUJ.js';import {explainDiagnostic}from'@docx-editor.dev/core';export{explainDiagnostic}from'@docx-editor.dev/core';var X={documentOpen:true,documentSave:true,sessions:true,editing:true,trackedChanges:true,fonts:true,history:true,customNodes:true,collaborationClient:true,performance:true},Y=new Set(["edit.tracking-required","edit.suggesting-needs-author","edit.tracking-mode-unsupported","edit.tracking-author-required","edit.tracking-unsupported-host"]);function Z(e){switch(e.kind){case "document.open":return "documentOpen";case "document.save":return "documentSave";case "session":return "sessions";case "edit.rejected":return e.tracked||Y.has(e.code)?"trackedChanges":"editing";case "review.skipped":return "trackedChanges";case "font.failed":return "fonts";case "history":return "history";case "custom-node":return "customNodes";case "collab.status":case "collab.participants":return "collaborationClient";case "performance":return "performance";default:return null}}function J(e){return e.kind==="collab.participants"?`${e.editorSessionId}|participants`:e.kind==="edit.rejected"?[e.editorSessionId,e.code,e.cause,e.errorName,e.stage,e.origin,e.tracked,e.command].join("|"):e.kind==="font.failed"?`${e.editorSessionId}|font|${e.code}`:e.kind==="history"||e.kind==="custom-node"?`${e.editorSessionId}|${e.kind}|${e.code}`:e.kind==="collab.status"?`${e.editorSessionId}|status|${e.status}|${e.code??""}|${e.recovering}`:null}function Q(e){let t=c(e),m=e.metricLabels??[];function n(o){let r={};try{let a=typeof e.attributes=="function"?e.attributes():e.attributes;r=c$1(a??{},"attributes");}catch{}return Object.freeze({...r,...o})}function c$2(o){let r={};for(let a of m)o[a]!==void 0&&(r[a]=o[a]);return r}let f=e.include??X,l=e.dedupeWindowMs??1e4,g=o=>{try{if(e.filter&&!e.filter(o))return}catch{return}t.event(o);},E=e.documentShape==="detailed",y=new Map,S=false,k=()=>{},T=false;function D(o){let r=f[o];return r?r===true?1:Math.min(1,Math.max(0,r.sampleRate??1)):null}let h=new Map;function w(o,r,a){if(a>=1)return true;if(r!=="performance"&&r!=="sessions")return Math.random()<a;let p=`${r}|${o.editorSessionId}`,b=h.get(p);return b===void 0&&(h.size>=1e3&&h.clear(),b=Math.random()<a,h.set(p,b)),b}function M(){if(S)return;S=true;let o=globalThis;if(typeof o.addEventListener!="function")return;let r=()=>{t.setPageClosing(true),u();},a=()=>{let p=globalThis.document?.visibilityState==="hidden";t.setPageClosing(p),p&&u();};o.addEventListener("pagehide",r),o.addEventListener("visibilitychange",a),k=()=>{o.removeEventListener?.("pagehide",r),o.removeEventListener?.("visibilitychange",a);};}function v(o){let r=y.get(o);if(r){if(y.delete(o),clearTimeout(r.timer),r.latest){let a=r.event;r.latest.count!==a.count&&g(I(F(r.latest,E),t,r.event.attributes));}else if(r.repeated>0){let{connectMs:a$1,...p}=r.event;g({...p,repeated:Math.min(r.repeated,a)});}}}function i(o,r={}){if(T||!t.consented())return;let a=n(r),p=b(I(o,t,a));if("dropped"in p){t.metric(a$1.droppedEvents,1,{...c$2(a),reason:p.dropped,"docx_editor.version":b$1});return}let b$2=Z(o),A=b$2?D(b$2):null;if(A===null||b$2==="performance"&&A===0)return;M(),ee(t,p.event,c$2(a));let x=l>0?J(o):null,O=x?y.get(x):void 0;if(O){o.kind==="collab.participants"?O.latest=o:O.repeated+=1;return}let _=o.kind==="session"&&o.phase==="end";if(!w(o,b$2,A)){_&&u();return}let L=I(F(o,E),t,a);if(g(L),_&&u(),x){let R=setTimeout(()=>v(x),l);R.unref?.(),y.set(x,{event:L,repeated:0,timer:R});}}function d(o){return Object.freeze({id:"telemetry",onDiagnostic:r=>i(r,o),wantsDiagnostic:r=>!T&&(r==="session"?D("sessions")!==null:r!=="performance"||(D("performance")??0)>0&&t.consented())})}async function u(){for(let o of [...y.keys()])v(o);await t.flush();}return {module:d({}),moduleFor(o){return d(c$1(o,"moduleFor"))},record:i,flush:u,async shutdown(){for(let o of [...y.keys()])v(o);T=true,k(),await t.shutdown();}}}function F(e,t){if(t||e.kind!=="document.open"||e.outcome!=="opened"||!("shape"in e))return e;let{shape:m,...n}=e;return n}function I(e,t,m){let n="code"in e?e.code:void 0;return {...e,...n?explainDiagnostic(n):{},engineVersion:b$1,resource:t.resource,attributes:m}}function ee(e,t,m){let n={metric:(c,f,l={})=>e.metric(c,f,{...l,...m,"docx_editor.version":b$1})};switch(t.kind){case "document.open":t.outcome==="opened"?(n.metric(a$1.documentOpens,1,{outcome:"opened",code:"none",cause:"none",part:"none"}),n.metric(a$1.documentOpenDuration,t.durationMs/1e3)):n.metric(a$1.documentOpens,1,{outcome:"rejected",code:t.code,cause:t.cause??"none",part:t.part??"none"});return;case "edit.rejected":n.metric(a$1.editRejections,1,{code:t.code,cause:t.cause??"none",origin:t.origin,tracked:t.tracked});return;case "review.skipped":n.metric(a$1.reviewSkipped,t.count,{code:t.code,cause:t.cause??"none",operation:t.operation??"none",change_kind:t.changeKind??"none"});return;case "font.failed":n.metric(a$1.fontFailures,1,{code:t.code});return;case "history":n.metric(a$1.historyDiagnostics,1,{code:t.code});return;case "custom-node":n.metric(a$1.customNodeDiagnostics,1,{code:t.code});return;case "collab.status":n.metric(a$1.collabClientStatusTransitions,1,{status:t.status,code:t.code??"none"}),t.connectMs!==void 0&&n.metric(a$1.collabClientConnectDuration,t.connectMs/1e3);return;case "collab.participants":n.metric(a$1.collabClientParticipants,t.count);return;case "performance":{let c=t.pagesBucket??"unknown";N(n,a$1.editorInteractionDuration,c,t.interaction),N(n,a$1.editorLayoutDuration,c,t.layout),N(n,a$1.editorPaintDuration,c,t.paint);return}case "document.save":n.metric(a$1.documentSaves,1,{outcome:t.outcome,code:t.outcome==="rejected"?t.code:"none"}),t.outcome==="saved"&&n.metric(a$1.documentSaveDuration,t.durationMs/1e3);return;case "session":te(n,t);}}function te(e,t){if(t.phase==="start"){e.metric(a$1.editorSessions,1,{phase:"start"});return}if(t.phase==="end"){e.metric(a$1.editorSessions,1,{phase:"end"}),e.metric(a$1.editorSessionDuration,t.durationMs/1e3);return}t.activeMs>0&&e.metric(a$1.editorActiveTime,t.activeMs/1e3);for(let[m,n]of [["input",t.inputEdits],["command",t.commandEdits],["automation",t.automationEdits]])n>0&&e.metric(a$1.edits,n,{origin:m});}function N(e,t,m,n){if(!n)return;let c=t.buckets??[];n.bands.forEach((f,l)=>{let g=c[l],E=l===0?0:c[l-1],y=g===void 0?Math.max(n.maxMs/1e3,E+1e-6):(E+g)/2;for(let S=0;S<f;S+=1)e.metric(t,y,{pages:m});});}var oe=new TextEncoder,ne=e=>oe.encode(e).byteLength;function re(e){let t=Math.min(50,Math.max(1,Math.floor(e.batchSize??20))),m=e.flushIntervalMs??1e4,n=[],c,f,l=new Set,g=i=>{try{e.onError?.(i);}catch{}},E=(i,d)=>{if(typeof fetch!="function")return;let u=fetch(e.url,{method:"POST",headers:{"Content-Type":"application/json",...e.headers},body:i,keepalive:d}).then(o=>{if(!o.ok)throw new Error(`telemetry endpoint answered ${o.status}`)}).catch(g).finally(()=>l.delete(u));l.add(u);},y=i=>{let d=globalThis.navigator;if(!e.headers&&typeof d?.sendBeacon=="function")try{if(d.sendBeacon(e.url,new Blob([i],{type:"application/json"})))return}catch{}E(i,true);},S=i=>{let d=Math.min(t,n.length);for(;d>0;){let u=JSON.stringify(n.slice(0,d)),o=ne(u);if(o<=i)return {body:u,bytes:o,count:d};if(d===1)return null;d=Math.ceil(d/2);}return null},k=0,T=false,D=i=>{if(c!==void 0&&clearTimeout(c),c=void 0,f&&!f.consented()){n=[];return}(!i||!T)&&(k=0),T=i;let d=0;for(;n.length;){let u=S(61440-(i?k:0));if(!u){d+=i?n.length:1,n=i?[]:n.slice(1);continue}n=n.slice(u.count),i?(k+=u.bytes,y(u.body)):E(u.body,false);}d&&g(new Error(`telemetry dropped ${d} events that did not fit`));},h=()=>{let i=f?.pageClosing()??false;return i||(T=false),i},w=globalThis.document,M=()=>{w?.visibilityState==="visible"&&(T=false);},v=()=>w?.removeEventListener?.("visibilitychange",M);return {name:"http",attach(i){f=i,w?.addEventListener?.("visibilitychange",M);},event(i){n.push(i),n.length>=t?D(h()):c===void 0&&(c=setTimeout(()=>D(h()),m),c.unref?.());},async flush(){D(h()),await Promise.all(l);},async shutdown(){v(),D(h()),await Promise.all(l);}}}function ie(e={}){return ()=>{let t=globalThis;if(!t.window||!t.document||!t.navigator||t.navigator.globalPrivacyControl===true||t.navigator.doNotTrack==="1")return false;if(!e.optOutKey)return true;try{return globalThis.localStorage?.getItem(e.optOutKey)!=="off"}catch{return true}}}export{ie as browserPrivacySignals,Q as createTelemetry,re as httpExporter};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
'use strict';var chunkE2HR5DHZ_cjs=require('../chunk-E2HR5DHZ.cjs'),chunk7SYLCJFQ_cjs=require('../chunk-7SYLCJFQ.cjs');require('../chunk-WGWKNKUT.cjs');var b=50,o=e=>typeof e=="string",u=e=>typeof e=="number"&&Number.isFinite(e),p=e=>typeof e=="boolean",h={"edit.rejected":[["code",o],["origin",o],["tracked",p]],"review.skipped":[["code",o],["count",u]],"font.failed":[["code",o]],history:[["code",o]],"custom-node":[["code",o]],"collab.status":[["status",o],["previous",o],["recovering",p]],"collab.participants":[["count",u]],performance:[["windowMs",u]]},E=["durationMs","activeMs","inputEdits","commandEdits","automationEdits"];function T(e){if(!u(e.schemaVersion)||!u(e.time)||!o(e.editorSessionId)||!o(e.engineVersion))return false;if(e.kind==="document.open")return e.outcome==="opened"?o(e.sizeBucket)&&u(e.durationMs):e.outcome==="rejected"&&o(e.code);if(e.kind==="document.save")return u(e.durationMs)&&(e.outcome==="saved"?o(e.sizeBucket):e.outcome==="rejected"&&o(e.code));if(e.kind==="session")return e.phase==="start"||(e.phase==="active"||e.phase==="end")&&E.every(t=>u(e[t]));let n=h[e.kind];return !!n&&n.every(([t,s])=>s(e[t]))}function w(e,n){if(n.size===0)return {};let t=chunk7SYLCJFQ_cjs.d(e)??{};return Object.fromEntries(Object.entries(t).filter(([s])=>n.has(s)))}function R(e,n={}){let t=n.maxEvents??b,s=new Set(n.attributeKeys??[]);return !Array.isArray(e)||e.length>t?[]:e.flatMap(r=>{if(!r||typeof r!="object"||Array.isArray(r))return [];let a=r,{resource:d,attributes:l,...i}=a,c=chunk7SYLCJFQ_cjs.b(i);if("dropped"in c||!T(c.event))return [];let g=w(a.attributes,s),{attributes:O,...k}=c.event;return [Object.freeze({...k,resource:w(a.resource,s),attributes:g})]})}function _(e){let{hint:n,docs:t,...s}=e,r="code"in e&&e.code?` ${e.code}`:"";return {...s,dt:e.time,message:`${e.kind}${r}`,occurrences:e.repeated??1,failures:chunkE2HR5DHZ_cjs.d(e)}}var x=64*1024;async function A(e,n){if(Number(e.headers.get("content-length")??0)>n)return null;let t=e.body;if(!t)return "";let s=t.getReader(),r=[],a=0;for(;;){let{done:i,value:c}=await s.read();if(i)break;if(a+=c.byteLength,a>n)return await s.cancel().catch(()=>{}),null;r.push(c);}let d=new Uint8Array(a),l=0;for(let i of r)d.set(i,l),l+=i.byteLength;return new TextDecoder().decode(d)}function F(e){let n=e.maxBodyBytes??x,t=()=>new Response(null,{status:204}),s=r=>{try{e.onError?.(r);}catch{}};return async r=>{if(r.method!=="POST")return t();if(e.allowedOrigins){let i=r.headers.get("origin");if(!i||!e.allowedOrigins.includes(i))return t()}let a;try{a=await A(r,n);}catch{return t()}if(a===null)return t();let d;try{d=JSON.parse(a);}catch{return t()}let l=R(d,e);if(!l.length)return t();try{let c=await e.forward(l);if(c&&c.ok===!1)throw new Error(`the log service answered ${String(c.status)}`)}catch(i){s(i);}return t()}}exports.telemetryRoute=F;exports.toLogRecord=_;exports.validateTelemetryEvents=R;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receive editor telemetry on your server and forward it to a log service, such as Better Stack,
|
|
3
|
+
* Axiom, or Datadog Logs. Pair it with `httpExporter` in the browser: the browser posts to your
|
|
4
|
+
* route, and only your server holds the log service's token.
|
|
5
|
+
*
|
|
6
|
+
* Anyone can POST to a public route, so every event is checked again with the same closed lists
|
|
7
|
+
* the browser used, and fields the client chose itself are dropped unless you allow them. No
|
|
8
|
+
* dependency: Fetch API `Request` and `Response` only, so it runs in Next.js route handlers,
|
|
9
|
+
* Hono, Bun, Deno, and Cloudflare Workers.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
import { a as TelemetryEvent } from '../pipeline-CCq2Ng0B.cjs';
|
|
14
|
+
import '@docx-editor.dev/core';
|
|
15
|
+
|
|
16
|
+
/** Options for {@link validateTelemetryEvents}. @public */
|
|
17
|
+
interface ValidateTelemetryEventsOptions {
|
|
18
|
+
/** The most events one request may carry. Defaults to 50; a longer list is refused whole. */
|
|
19
|
+
readonly maxEvents?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Keys of the browser's `resource` and `attributes` to keep, such as `['app.version']`. Each
|
|
22
|
+
* kept value must be a number, a boolean, or an identifier string. Defaults to none: a value
|
|
23
|
+
* the client chose cannot be checked for meaning, so add your fixed fields on the server.
|
|
24
|
+
*/
|
|
25
|
+
readonly attributeKeys?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The telemetry events in an untrusted request body, checked again with the package's own
|
|
29
|
+
* closed lists. An event with an unknown kind or code, a code of another kind, a value outside
|
|
30
|
+
* its list or range, or without the fields its kind needs, is dropped. An unknown field, a
|
|
31
|
+
* number or timing summary its kind does not carry, or a wall-clock span above its bound is
|
|
32
|
+
* removed. The client's `resource` and `attributes` keep only the keys in `attributeKeys`.
|
|
33
|
+
* Returns an empty list for a body that is not an array or is longer than `maxEvents`.
|
|
34
|
+
*
|
|
35
|
+
* The check proves that every value comes from the package's lists or is a number in the range
|
|
36
|
+
* the engine produces. It cannot prove that a client told the truth: a script can send a
|
|
37
|
+
* well-formed event that never happened. It can never send text.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const events = validateTelemetryEvents(await request.json(), { attributeKeys: ['app.version'] });
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
declare function validateTelemetryEvents(body: unknown, options?: ValidateTelemetryEventsOptions): TelemetryEvent[];
|
|
47
|
+
/** A log record with the fields log services read for time, summary, and counts. @public */
|
|
48
|
+
type TelemetryLogRecord = Record<string, unknown> & {
|
|
49
|
+
/** The event time in milliseconds since the epoch, which Better Stack and others read. */
|
|
50
|
+
readonly dt: number;
|
|
51
|
+
/** A one-line summary, such as `edit.rejected edit.picture-selected`. */
|
|
52
|
+
readonly message: string;
|
|
53
|
+
/**
|
|
54
|
+
* How many times the event happened: `1`, or `repeated` for a deduplication summary, whose
|
|
55
|
+
* first event was already sent. Sum it to count events; do not count records.
|
|
56
|
+
*/
|
|
57
|
+
readonly occurrences: number;
|
|
58
|
+
/** How many failures the record adds to a failure total. See `failureCount`. */
|
|
59
|
+
readonly failures: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* An event as a log record: `dt` for the time, `message` for the live tail, `occurrences` and
|
|
63
|
+
* `failures` to sum, and every field of the event except the fixed `hint` and `docs` text,
|
|
64
|
+
* which the code already identifies. Add your own fixed fields, such as a service name, to the
|
|
65
|
+
* result.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* body: JSON.stringify(events.map((event) => ({ ...toLogRecord(event), service: 'my-app' })))
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
declare function toLogRecord(event: TelemetryEvent): TelemetryLogRecord;
|
|
75
|
+
/** Options for {@link telemetryRoute}. @public */
|
|
76
|
+
interface TelemetryRouteOptions extends ValidateTelemetryEventsOptions {
|
|
77
|
+
/** Send the checked events on, such as to your log service. Errors are caught and reported. */
|
|
78
|
+
forward(events: readonly TelemetryEvent[]): unknown;
|
|
79
|
+
/** Origins allowed to post, such as `['https://app.example.com']`. Omit to allow any. */
|
|
80
|
+
readonly allowedOrigins?: readonly string[];
|
|
81
|
+
/** The largest body accepted, in bytes. Defaults to 64 KiB. */
|
|
82
|
+
readonly maxBodyBytes?: number;
|
|
83
|
+
/** Called when `forward` throws or rejects, or the log service refuses. Defaults to nothing. */
|
|
84
|
+
readonly onError?: (error: unknown) => void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A Fetch-style handler for telemetry from `httpExporter`: it accepts POST only, checks the
|
|
88
|
+
* origin, reads at most `maxBodyBytes`, validates every event, and calls `forward` with what
|
|
89
|
+
* passes. It answers 204 to every request, so a telemetry problem never surfaces in the
|
|
90
|
+
* editor; use `onError` to see failures. It does not rate-limit; put it behind your platform's
|
|
91
|
+
* limits.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* // Next.js: app/api/telemetry/route.ts
|
|
96
|
+
* import { telemetryRoute, toLogRecord } from '@docx-editor.dev/telemetry/ingest';
|
|
97
|
+
*
|
|
98
|
+
* export const POST = telemetryRoute({
|
|
99
|
+
* forward: (events) =>
|
|
100
|
+
* fetch(`https://${process.env.BETTERSTACK_INGESTING_HOST}`, {
|
|
101
|
+
* method: 'POST',
|
|
102
|
+
* headers: {
|
|
103
|
+
* 'Content-Type': 'application/json',
|
|
104
|
+
* Authorization: `Bearer ${process.env.BETTERSTACK_SOURCE_TOKEN}`,
|
|
105
|
+
* },
|
|
106
|
+
* body: JSON.stringify(events.map(toLogRecord)),
|
|
107
|
+
* }),
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @public
|
|
112
|
+
*/
|
|
113
|
+
declare function telemetryRoute(options: TelemetryRouteOptions): (request: Request) => Promise<Response>;
|
|
114
|
+
|
|
115
|
+
export { TelemetryEvent, telemetryRoute, toLogRecord, validateTelemetryEvents };
|
|
116
|
+
export type { TelemetryLogRecord, TelemetryRouteOptions, ValidateTelemetryEventsOptions };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receive editor telemetry on your server and forward it to a log service, such as Better Stack,
|
|
3
|
+
* Axiom, or Datadog Logs. Pair it with `httpExporter` in the browser: the browser posts to your
|
|
4
|
+
* route, and only your server holds the log service's token.
|
|
5
|
+
*
|
|
6
|
+
* Anyone can POST to a public route, so every event is checked again with the same closed lists
|
|
7
|
+
* the browser used, and fields the client chose itself are dropped unless you allow them. No
|
|
8
|
+
* dependency: Fetch API `Request` and `Response` only, so it runs in Next.js route handlers,
|
|
9
|
+
* Hono, Bun, Deno, and Cloudflare Workers.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
import { a as TelemetryEvent } from '../pipeline-CCq2Ng0B.js';
|
|
14
|
+
import '@docx-editor.dev/core';
|
|
15
|
+
|
|
16
|
+
/** Options for {@link validateTelemetryEvents}. @public */
|
|
17
|
+
interface ValidateTelemetryEventsOptions {
|
|
18
|
+
/** The most events one request may carry. Defaults to 50; a longer list is refused whole. */
|
|
19
|
+
readonly maxEvents?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Keys of the browser's `resource` and `attributes` to keep, such as `['app.version']`. Each
|
|
22
|
+
* kept value must be a number, a boolean, or an identifier string. Defaults to none: a value
|
|
23
|
+
* the client chose cannot be checked for meaning, so add your fixed fields on the server.
|
|
24
|
+
*/
|
|
25
|
+
readonly attributeKeys?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The telemetry events in an untrusted request body, checked again with the package's own
|
|
29
|
+
* closed lists. An event with an unknown kind or code, a code of another kind, a value outside
|
|
30
|
+
* its list or range, or without the fields its kind needs, is dropped. An unknown field, a
|
|
31
|
+
* number or timing summary its kind does not carry, or a wall-clock span above its bound is
|
|
32
|
+
* removed. The client's `resource` and `attributes` keep only the keys in `attributeKeys`.
|
|
33
|
+
* Returns an empty list for a body that is not an array or is longer than `maxEvents`.
|
|
34
|
+
*
|
|
35
|
+
* The check proves that every value comes from the package's lists or is a number in the range
|
|
36
|
+
* the engine produces. It cannot prove that a client told the truth: a script can send a
|
|
37
|
+
* well-formed event that never happened. It can never send text.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const events = validateTelemetryEvents(await request.json(), { attributeKeys: ['app.version'] });
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
declare function validateTelemetryEvents(body: unknown, options?: ValidateTelemetryEventsOptions): TelemetryEvent[];
|
|
47
|
+
/** A log record with the fields log services read for time, summary, and counts. @public */
|
|
48
|
+
type TelemetryLogRecord = Record<string, unknown> & {
|
|
49
|
+
/** The event time in milliseconds since the epoch, which Better Stack and others read. */
|
|
50
|
+
readonly dt: number;
|
|
51
|
+
/** A one-line summary, such as `edit.rejected edit.picture-selected`. */
|
|
52
|
+
readonly message: string;
|
|
53
|
+
/**
|
|
54
|
+
* How many times the event happened: `1`, or `repeated` for a deduplication summary, whose
|
|
55
|
+
* first event was already sent. Sum it to count events; do not count records.
|
|
56
|
+
*/
|
|
57
|
+
readonly occurrences: number;
|
|
58
|
+
/** How many failures the record adds to a failure total. See `failureCount`. */
|
|
59
|
+
readonly failures: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* An event as a log record: `dt` for the time, `message` for the live tail, `occurrences` and
|
|
63
|
+
* `failures` to sum, and every field of the event except the fixed `hint` and `docs` text,
|
|
64
|
+
* which the code already identifies. Add your own fixed fields, such as a service name, to the
|
|
65
|
+
* result.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* body: JSON.stringify(events.map((event) => ({ ...toLogRecord(event), service: 'my-app' })))
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
declare function toLogRecord(event: TelemetryEvent): TelemetryLogRecord;
|
|
75
|
+
/** Options for {@link telemetryRoute}. @public */
|
|
76
|
+
interface TelemetryRouteOptions extends ValidateTelemetryEventsOptions {
|
|
77
|
+
/** Send the checked events on, such as to your log service. Errors are caught and reported. */
|
|
78
|
+
forward(events: readonly TelemetryEvent[]): unknown;
|
|
79
|
+
/** Origins allowed to post, such as `['https://app.example.com']`. Omit to allow any. */
|
|
80
|
+
readonly allowedOrigins?: readonly string[];
|
|
81
|
+
/** The largest body accepted, in bytes. Defaults to 64 KiB. */
|
|
82
|
+
readonly maxBodyBytes?: number;
|
|
83
|
+
/** Called when `forward` throws or rejects, or the log service refuses. Defaults to nothing. */
|
|
84
|
+
readonly onError?: (error: unknown) => void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A Fetch-style handler for telemetry from `httpExporter`: it accepts POST only, checks the
|
|
88
|
+
* origin, reads at most `maxBodyBytes`, validates every event, and calls `forward` with what
|
|
89
|
+
* passes. It answers 204 to every request, so a telemetry problem never surfaces in the
|
|
90
|
+
* editor; use `onError` to see failures. It does not rate-limit; put it behind your platform's
|
|
91
|
+
* limits.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* // Next.js: app/api/telemetry/route.ts
|
|
96
|
+
* import { telemetryRoute, toLogRecord } from '@docx-editor.dev/telemetry/ingest';
|
|
97
|
+
*
|
|
98
|
+
* export const POST = telemetryRoute({
|
|
99
|
+
* forward: (events) =>
|
|
100
|
+
* fetch(`https://${process.env.BETTERSTACK_INGESTING_HOST}`, {
|
|
101
|
+
* method: 'POST',
|
|
102
|
+
* headers: {
|
|
103
|
+
* 'Content-Type': 'application/json',
|
|
104
|
+
* Authorization: `Bearer ${process.env.BETTERSTACK_SOURCE_TOKEN}`,
|
|
105
|
+
* },
|
|
106
|
+
* body: JSON.stringify(events.map(toLogRecord)),
|
|
107
|
+
* }),
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @public
|
|
112
|
+
*/
|
|
113
|
+
declare function telemetryRoute(options: TelemetryRouteOptions): (request: Request) => Promise<Response>;
|
|
114
|
+
|
|
115
|
+
export { TelemetryEvent, telemetryRoute, toLogRecord, validateTelemetryEvents };
|
|
116
|
+
export type { TelemetryLogRecord, TelemetryRouteOptions, ValidateTelemetryEventsOptions };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import {d}from'../chunk-WLWS5CZT.js';import {b as b$1,d as d$1}from'../chunk-W5HFB5EV.js';import'../chunk-DT2KMPUJ.js';var b=50,o=e=>typeof e=="string",u=e=>typeof e=="number"&&Number.isFinite(e),p=e=>typeof e=="boolean",h={"edit.rejected":[["code",o],["origin",o],["tracked",p]],"review.skipped":[["code",o],["count",u]],"font.failed":[["code",o]],history:[["code",o]],"custom-node":[["code",o]],"collab.status":[["status",o],["previous",o],["recovering",p]],"collab.participants":[["count",u]],performance:[["windowMs",u]]},E=["durationMs","activeMs","inputEdits","commandEdits","automationEdits"];function T(e){if(!u(e.schemaVersion)||!u(e.time)||!o(e.editorSessionId)||!o(e.engineVersion))return false;if(e.kind==="document.open")return e.outcome==="opened"?o(e.sizeBucket)&&u(e.durationMs):e.outcome==="rejected"&&o(e.code);if(e.kind==="document.save")return u(e.durationMs)&&(e.outcome==="saved"?o(e.sizeBucket):e.outcome==="rejected"&&o(e.code));if(e.kind==="session")return e.phase==="start"||(e.phase==="active"||e.phase==="end")&&E.every(t=>u(e[t]));let n=h[e.kind];return !!n&&n.every(([t,s])=>s(e[t]))}function w(e,n){if(n.size===0)return {};let t=d$1(e)??{};return Object.fromEntries(Object.entries(t).filter(([s])=>n.has(s)))}function R(e,n={}){let t=n.maxEvents??b,s=new Set(n.attributeKeys??[]);return !Array.isArray(e)||e.length>t?[]:e.flatMap(r=>{if(!r||typeof r!="object"||Array.isArray(r))return [];let a=r,{resource:d,attributes:l,...i}=a,c=b$1(i);if("dropped"in c||!T(c.event))return [];let g=w(a.attributes,s),{attributes:O,...k}=c.event;return [Object.freeze({...k,resource:w(a.resource,s),attributes:g})]})}function _(e){let{hint:n,docs:t,...s}=e,r="code"in e&&e.code?` ${e.code}`:"";return {...s,dt:e.time,message:`${e.kind}${r}`,occurrences:e.repeated??1,failures:d(e)}}var x=64*1024;async function A(e,n){if(Number(e.headers.get("content-length")??0)>n)return null;let t=e.body;if(!t)return "";let s=t.getReader(),r=[],a=0;for(;;){let{done:i,value:c}=await s.read();if(i)break;if(a+=c.byteLength,a>n)return await s.cancel().catch(()=>{}),null;r.push(c);}let d=new Uint8Array(a),l=0;for(let i of r)d.set(i,l),l+=i.byteLength;return new TextDecoder().decode(d)}function F(e){let n=e.maxBodyBytes??x,t=()=>new Response(null,{status:204}),s=r=>{try{e.onError?.(r);}catch{}};return async r=>{if(r.method!=="POST")return t();if(e.allowedOrigins){let i=r.headers.get("origin");if(!i||!e.allowedOrigins.includes(i))return t()}let a;try{a=await A(r,n);}catch{return t()}if(a===null)return t();let d;try{d=JSON.parse(a);}catch{return t()}let l=R(d,e);if(!l.length)return t();try{let c=await e.forward(l);if(c&&c.ok===!1)throw new Error(`the log service answered ${String(c.status)}`)}catch(i){s(i);}return t()}}export{F as telemetryRoute,_ as toLogRecord,R as validateTelemetryEvents};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
'use strict';var chunkE2HR5DHZ_cjs=require('../chunk-E2HR5DHZ.cjs'),chunkWGWKNKUT_cjs=require('../chunk-WGWKNKUT.cjs'),api=require('@opentelemetry/api');var E="@docx-editor.dev/telemetry";function M(o){let n=o.getDelegate?.()??o;return /^Noop/.test(n?.constructor?.name??"")}var k=false;function h(o,n){let s=chunkWGWKNKUT_cjs.a();k||s==="production"||!M(o)||!M(n)||(k=true,console.warn("[docx-editor telemetry] otelExporter found no OpenTelemetry SDK. Register a MeterProvider and a TracerProvider before creating the exporter, or pass them as options. Until then every measurement is dropped."));}function C(o,n,s){return o.createObservableGauge(n.name,{unit:n.unit,description:n.description}).addCallback(t=>{for(let{value:i,attributes:a}of s.values())t.observe(i,a);}),{record(t,i={}){s.set(g(i),{value:t,attributes:i});},forget(t){s.delete(g(t));}}}function g(o){return JSON.stringify(Object.keys(o).sort().map(n=>[n,o[n]]))}function f(o){let n={...o.resource,...o.attributes};for(let[s,t]of Object.entries(o))if(!(s==="resource"||s==="attributes"||s==="hint"||s==="docs")){if(typeof t=="string"||typeof t=="number"||typeof t=="boolean")n[`docx_editor.${s}`]=t;else if(s==="shape"&&t&&typeof t=="object")for(let[i,a]of Object.entries(t))typeof a=="string"&&(n[`docx_editor.shape.${i}`]=a);else if(t&&typeof t=="object"&&!Array.isArray(t))for(let[i,a]of Object.entries(t))typeof a=="number"&&(n[`docx_editor.${s}.${i}`]=a);}return n}function j(o={}){let n=o.scope??E,s=()=>o.meterProvider??api.metrics.getMeterProvider(),t=()=>o.tracerProvider??api.trace.getTracerProvider(),i,a,l=new Map,m=new Map,b=false;function P(e){let r=m.get(e);return r||(r=new Map,m.set(e,r)),r}function T(){let e=s();return (e!==i||!a)&&(i=e,a=e.getMeter(n),l.clear()),a}function A(e){let r=T();b||(b=true,setTimeout(()=>h(s(),t()),5e3).unref?.());let c=l.get(e.name);if(c)return c;let u={unit:e.unit,description:e.description},d=e.kind==="counter"?r.createCounter(e.name,u):e.kind==="histogram"?r.createHistogram(e.name,{...u,...e.buckets?{advice:{explicitBucketBoundaries:[...e.buckets]}}:{}}):C(r,e,P(e.name));return l.set(e.name,d),d}return {name:"otel",metric(e){let r=A(e.definition),c=e.attributes;"add"in r?r.add(e.value,c):r.record(e.value,c);},event(e){if(e.kind==="document.open"||e.kind==="document.save"){let u=e.kind==="document.save"||e.outcome==="opened"?e.durationMs:0,d=e.time-u,p=t().getTracer(n).startSpan(`docx_editor.${e.kind}`,{startTime:d,attributes:f(e)},o.parentContext?.());e.outcome==="rejected"&&p.setStatus({code:api.SpanStatusCode.ERROR,message:e.code}),p.end(e.time);return}let r=api.trace.getActiveSpan();if(r){r.addEvent(`docx_editor.${e.kind}`,f(e));return}if(!chunkE2HR5DHZ_cjs.c(e))return;let c=t().getTracer(n).startSpan(`docx_editor.${e.kind}`,{startTime:e.time,attributes:f(e)},o.parentContext?.());c.setStatus({code:api.SpanStatusCode.ERROR,message:"code"in e?e.code:e.kind}),c.end(e.time);},forget(e,r){m.get(e.name)?.delete(g(r));},async flush(){let e=r=>(r.getDelegate?.()??r).forceFlush?.();await Promise.all([e(s()),e(t())]);}}}exports.otelExporter=j;
|