@dash0/sdk-web 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dash0.iife.js +1 -1
- package/dist/dash0.iife.js.map +1 -1
- package/dist/dash0.js +1 -1
- package/dist/dash0.js.map +1 -1
- package/dist/dash0.umd.cjs +1 -1
- package/dist/dash0.umd.cjs.map +1 -1
- package/dist/modules/api/init.js +2 -0
- package/dist/modules/api/init_test.js +220 -1
- package/dist/modules/api/vcs.js +196 -0
- package/dist/modules/instrumentations/http/fetch.js +17 -3
- package/dist/modules/instrumentations/http/fetch_test.js +103 -0
- package/dist/modules/semantic-conventions.js +9 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/api/vcs.d.ts +10 -0
- package/dist/types/semantic-conventions.d.ts +8 -0
- package/dist/types/types/options.d.ts +53 -0
- package/package.json +1 -1
- package/src/api/init.ts +3 -0
- package/src/api/init_test.ts +288 -1
- package/src/api/vcs.ts +359 -0
- package/src/instrumentations/http/fetch.ts +16 -2
- package/src/instrumentations/http/fetch_test.ts +123 -0
- package/src/semantic-conventions.ts +10 -0
- package/src/types/options.ts +56 -0
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
HTTP_RESPONSE_STATUS_CODE,
|
|
30
30
|
SPAN_STATUS_ERROR,
|
|
31
31
|
SPAN_STATUS_UNSET,
|
|
32
|
+
WEB_REQUEST_CANCELLED,
|
|
32
33
|
} from "../../semantic-conventions";
|
|
33
34
|
import { vars, PropagatorType } from "../../vars";
|
|
34
35
|
import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http";
|
|
@@ -129,12 +130,20 @@ function wrapFetch(original: typeof fetch) {
|
|
|
129
130
|
() => performanceObserver.end(),
|
|
130
131
|
(e) => {
|
|
131
132
|
performanceObserver.cancel();
|
|
132
|
-
|
|
133
|
+
if (request.signal?.aborted) {
|
|
134
|
+
endSpanOnAbort(span);
|
|
135
|
+
} else {
|
|
136
|
+
endSpanOnError(span, e);
|
|
137
|
+
}
|
|
133
138
|
}
|
|
134
139
|
);
|
|
135
140
|
} catch (e) {
|
|
136
141
|
performanceObserver.cancel();
|
|
137
|
-
|
|
142
|
+
if (request.signal?.aborted) {
|
|
143
|
+
endSpanOnAbort(span);
|
|
144
|
+
} else {
|
|
145
|
+
endSpanOnError(span, e as Exception);
|
|
146
|
+
}
|
|
138
147
|
throw e;
|
|
139
148
|
}
|
|
140
149
|
};
|
|
@@ -277,6 +286,11 @@ function endSpanOnError(span: InProgressSpan, error: Exception) {
|
|
|
277
286
|
sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
|
|
278
287
|
}
|
|
279
288
|
|
|
289
|
+
function endSpanOnAbort(span: InProgressSpan) {
|
|
290
|
+
addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
|
|
291
|
+
sendSpan(endSpan(span, undefined, undefined));
|
|
292
|
+
}
|
|
293
|
+
|
|
280
294
|
function determinePropagatorTypes(url: string): PropagatorType[] {
|
|
281
295
|
const matchingTypes: PropagatorType[] = [];
|
|
282
296
|
const isUrlSameOrigin = isSameOrigin(url);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { vars } from "../../vars";
|
|
3
3
|
import { instrumentFetch } from "./fetch";
|
|
4
|
+
import { sendSpan } from "../../transport";
|
|
5
|
+
import type { Span } from "../../types/otlp";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../transport", () => ({
|
|
8
|
+
sendSpan: vi.fn(),
|
|
9
|
+
}));
|
|
4
10
|
|
|
5
11
|
describe("fetch test", () => {
|
|
6
12
|
let fetchMock: any;
|
|
@@ -188,4 +194,121 @@ describe("fetch test", () => {
|
|
|
188
194
|
expect(fetchHeaders.get("traceparent")).not.toBeNull();
|
|
189
195
|
expect(fetchHeaders.get("X-Amzn-Trace-Id")).toBeNull();
|
|
190
196
|
});
|
|
197
|
+
|
|
198
|
+
describe("aborted requests", () => {
|
|
199
|
+
const sendSpanMock = sendSpan as unknown as ReturnType<typeof vi.fn>;
|
|
200
|
+
const NativeRequest = Request;
|
|
201
|
+
|
|
202
|
+
const lastSpan = (): Span => {
|
|
203
|
+
const calls = sendSpanMock.mock.calls;
|
|
204
|
+
return calls[calls.length - 1]![0] as Span;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const hasAttribute = (span: Span, key: string, expectedValue: unknown) =>
|
|
208
|
+
span.attributes.some((a) => a.key === key && JSON.stringify(a.value) === JSON.stringify(expectedValue));
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
sendSpanMock.mockClear();
|
|
212
|
+
// jsdom's Request rejects its own AbortSignal instances (known jsdom bug).
|
|
213
|
+
// Stub Request with a minimal implementation that preserves signals verbatim.
|
|
214
|
+
class FakeRequest {
|
|
215
|
+
url: string;
|
|
216
|
+
method: string;
|
|
217
|
+
headers: Headers;
|
|
218
|
+
signal: AbortSignal | undefined;
|
|
219
|
+
constructor(input: string | FakeRequest, init?: RequestInit) {
|
|
220
|
+
if (input instanceof FakeRequest) {
|
|
221
|
+
this.url = input.url;
|
|
222
|
+
this.method = init?.method ?? input.method;
|
|
223
|
+
this.headers = new Headers(init?.headers ?? input.headers);
|
|
224
|
+
this.signal = init?.signal ?? input.signal;
|
|
225
|
+
} else {
|
|
226
|
+
this.url = String(input);
|
|
227
|
+
this.method = init?.method ?? "GET";
|
|
228
|
+
this.headers = new Headers(init?.headers);
|
|
229
|
+
this.signal = init?.signal ?? undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
vi.stubGlobal("Request", FakeRequest);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
afterEach(() => {
|
|
237
|
+
vi.stubGlobal("Request", NativeRequest);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("marks fetch as cancelled when the abort signal fires before the response arrives", async () => {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
fetchMock.mockImplementation(
|
|
243
|
+
(_input: RequestInfo, init?: RequestInit) =>
|
|
244
|
+
new Promise((_resolve, reject) => {
|
|
245
|
+
const signal = init?.signal;
|
|
246
|
+
if (signal?.aborted) {
|
|
247
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
instrumentFetch();
|
|
255
|
+
// eslint-disable-next-line no-restricted-globals
|
|
256
|
+
const pending = fetch("http://localhost:3000/api/test", { signal: controller.signal });
|
|
257
|
+
controller.abort();
|
|
258
|
+
await expect(pending).rejects.toBeInstanceOf(DOMException);
|
|
259
|
+
|
|
260
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
const span = lastSpan();
|
|
262
|
+
expect(span.status?.code).toBe(0);
|
|
263
|
+
expect(span.status?.message).toBeUndefined();
|
|
264
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
|
|
265
|
+
expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("marks fetch as cancelled when the abort signal fires while reading the response body", async () => {
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
const body = new ReadableStream({
|
|
271
|
+
start(streamController) {
|
|
272
|
+
streamController.enqueue(new Uint8Array([0x68, 0x69]));
|
|
273
|
+
controller.signal.addEventListener("abort", () => {
|
|
274
|
+
streamController.error(new DOMException("aborted", "AbortError"));
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
fetchMock.mockImplementation(() => Promise.resolve(new Response(body, { status: 200 })));
|
|
280
|
+
|
|
281
|
+
instrumentFetch();
|
|
282
|
+
// eslint-disable-next-line no-restricted-globals
|
|
283
|
+
const response = await fetch("http://localhost:3000/api/test", { signal: controller.signal });
|
|
284
|
+
const reader = response.body!.getReader();
|
|
285
|
+
await reader.read();
|
|
286
|
+
controller.abort();
|
|
287
|
+
await expect(reader.read()).rejects.toBeInstanceOf(DOMException);
|
|
288
|
+
|
|
289
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
290
|
+
const span = lastSpan();
|
|
291
|
+
// status was set from the 200 response and should not be overwritten
|
|
292
|
+
expect(span.status?.code).toBe(0);
|
|
293
|
+
expect(span.status?.message).toBeUndefined();
|
|
294
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
|
|
295
|
+
expect(hasAttribute(span, "http.response.status_code", { stringValue: "200" })).toBe(true);
|
|
296
|
+
expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("still treats non-abort rejections as failures", async () => {
|
|
300
|
+
fetchMock.mockImplementation(() => Promise.reject(new TypeError("network down")));
|
|
301
|
+
|
|
302
|
+
instrumentFetch();
|
|
303
|
+
// eslint-disable-next-line no-restricted-globals
|
|
304
|
+
await expect(fetch("http://localhost:3000/api/test")).rejects.toBeInstanceOf(TypeError);
|
|
305
|
+
|
|
306
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
307
|
+
const span = lastSpan();
|
|
308
|
+
expect(span.status?.code).toBe(2);
|
|
309
|
+
expect(span.status?.message).toBe("network down");
|
|
310
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
|
|
311
|
+
expect(span.events.some((e) => e.name === "exception")).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
191
314
|
});
|
|
@@ -6,10 +6,20 @@ export const DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name";
|
|
|
6
6
|
export const DEPLOYMENT_NAME = "deployment.name";
|
|
7
7
|
export const DEPLOYMENT_ID = "deployment.id";
|
|
8
8
|
|
|
9
|
+
// VCS Resource Attribute Keys
|
|
10
|
+
export const VCS_PROVIDER_NAME = "vcs.provider.name";
|
|
11
|
+
export const VCS_OWNER_NAME = "vcs.owner.name";
|
|
12
|
+
export const VCS_REPOSITORY_NAME = "vcs.repository.name";
|
|
13
|
+
export const VCS_REPOSITORY_URL_FULL = "vcs.repository.url.full";
|
|
14
|
+
export const VCS_REF_HEAD_NAME = "vcs.ref.head.name";
|
|
15
|
+
export const VCS_REF_HEAD_REVISION = "vcs.ref.head.revision";
|
|
16
|
+
export const VCS_CHANGE_ID = "vcs.change.id";
|
|
17
|
+
|
|
9
18
|
// Misc Signal Attribute Keys
|
|
10
19
|
export const EVENT_NAME = "event.name";
|
|
11
20
|
export const WEB_EVENT_TITLE = "dash0.web.event.title";
|
|
12
21
|
export const WEB_EVENT_ID = "dash0.web.event.id";
|
|
22
|
+
export const WEB_REQUEST_CANCELLED = "dash0.web.request.cancelled";
|
|
13
23
|
export const PAGE_LOAD_ID = "page.load.id";
|
|
14
24
|
export const SESSION_ID = "session.id";
|
|
15
25
|
export const USER_AGENT = "user_agent.original";
|
package/src/types/options.ts
CHANGED
|
@@ -4,6 +4,29 @@ import { Endpoint, Vars, PropagatorConfig } from "../vars";
|
|
|
4
4
|
|
|
5
5
|
export type InstrumentationName = "@dash0/navigation" | "@dash0/web-vitals" | "@dash0/error" | "@dash0/fetch";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* VCS (version control) context describing the build the SDK is running
|
|
9
|
+
* inside. Used both as the public manual-override shape on `InitOptions.vcs`
|
|
10
|
+
* and internally as the merged result of auto-detection. Each field maps to
|
|
11
|
+
* a standard OpenTelemetry `vcs.*` resource attribute.
|
|
12
|
+
*/
|
|
13
|
+
export type VcsAttributes = {
|
|
14
|
+
/** vcs.provider.name — e.g. "github", "gitlab", "bitbucket". */
|
|
15
|
+
providerName?: string;
|
|
16
|
+
/** vcs.owner.name — repository owner / organization. */
|
|
17
|
+
ownerName?: string;
|
|
18
|
+
/** vcs.repository.name — short repository name (no owner prefix). */
|
|
19
|
+
repositoryName?: string;
|
|
20
|
+
/** vcs.repository.url.full — canonical repository URL. */
|
|
21
|
+
repositoryUrlFull?: string;
|
|
22
|
+
/** vcs.ref.head.name — branch or tag name the build was made from. */
|
|
23
|
+
refHeadName?: string;
|
|
24
|
+
/** vcs.ref.head.revision — commit SHA the build was made from. */
|
|
25
|
+
refHeadRevision?: string;
|
|
26
|
+
/** vcs.change.id — pull/merge request identifier (preview deploys). */
|
|
27
|
+
changeId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
7
30
|
export type InitOptions = {
|
|
8
31
|
serviceName: string;
|
|
9
32
|
serviceNamespace?: string;
|
|
@@ -28,6 +51,39 @@ export type InitOptions = {
|
|
|
28
51
|
*/
|
|
29
52
|
rejectSuspiciousServiceName?: boolean;
|
|
30
53
|
|
|
54
|
+
/**
|
|
55
|
+
* When `true`, disable auto-detection of VCS (version control) context
|
|
56
|
+
* from the build environment. By default the SDK reads VCS context from
|
|
57
|
+
* Vercel (`NEXT_PUBLIC_VERCEL_GIT_*`) and Netlify
|
|
58
|
+
* (`NEXT_PUBLIC_REPOSITORY_URL`, `NEXT_PUBLIC_BRANCH`,
|
|
59
|
+
* `NEXT_PUBLIC_COMMIT_REF`, `NEXT_PUBLIC_REVIEW_ID`) and applies the values
|
|
60
|
+
* as resource attributes following the OTel `vcs.*` semantic conventions:
|
|
61
|
+
*
|
|
62
|
+
* - vcs.provider.name
|
|
63
|
+
* - vcs.owner.name
|
|
64
|
+
* - vcs.repository.name
|
|
65
|
+
* - vcs.repository.url.full
|
|
66
|
+
* - vcs.ref.head.name
|
|
67
|
+
* - vcs.ref.head.revision
|
|
68
|
+
* - vcs.change.id
|
|
69
|
+
*
|
|
70
|
+
* Pairing telemetry with the git commit + branch the build came from lets
|
|
71
|
+
* Dash0 Agent answer questions like "which PR introduced this error?".
|
|
72
|
+
*
|
|
73
|
+
* Note: any fields supplied via `vcs` are still applied even when this flag
|
|
74
|
+
* is `true` — manual overrides always win. Set this flag when you want to
|
|
75
|
+
* prevent env-var reads entirely but still supply context explicitly.
|
|
76
|
+
*/
|
|
77
|
+
disableVcsDetection?: boolean;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Manually specify VCS (version control) context. Each provided field
|
|
81
|
+
* overrides the value the SDK would otherwise auto-detect from the build
|
|
82
|
+
* environment for that attribute. Use this for non-Vercel/Netlify
|
|
83
|
+
* deployments, or when the auto-detected values are wrong.
|
|
84
|
+
*/
|
|
85
|
+
vcs?: VcsAttributes;
|
|
86
|
+
|
|
31
87
|
/**
|
|
32
88
|
* OTLP endpoints to which the generated telemetry should be sent to.
|
|
33
89
|
*/
|