@dash0/sdk-web 0.18.2 → 0.18.3

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.
@@ -1,11 +1,17 @@
1
1
  import { vars } from "../vars";
2
2
  import { noop, warn, fetch, debug } from "../utils";
3
+ // The bacon api enforces a size limit across all pending beacon requests per fetch group
4
+ // There also seems to be a limit of 15 pending requests at the same time
5
+ // See: https://fetch.spec.whatwg.org/#concept-http-network-or-cache-fetch subpoint 10
6
+ // See: https://github.com/getsentry/sentry-javascript/pull/7553
3
7
  const BEACON_BODY_SIZE_LIMIT = 60000;
8
+ let pendingBodySize = 0;
9
+ let pendingRequestCount = 0;
4
10
  export async function send(path, body) {
5
11
  debug("Transmitting telemetry to endpoints", body);
6
12
  const jsonString = JSON.stringify(body);
7
13
  let requestBody = jsonString;
8
- let byteLength = jsonString.length;
14
+ let byteLength = new Blob([jsonString]).size;
9
15
  let isCompressed = false;
10
16
  // Try to compress if supported
11
17
  if (typeof CompressionStream !== "undefined" && vars.enableTransportCompression) {
@@ -33,12 +39,14 @@ export async function send(path, body) {
33
39
  warn("Unable to send telemetry, fetch is not defined");
34
40
  return;
35
41
  }
42
+ pendingBodySize += byteLength;
43
+ pendingRequestCount++;
36
44
  const response = await fetch(url, {
37
45
  method: "POST",
38
46
  headers,
39
47
  body: requestBody,
40
48
  // The keepalive flag is related to the window.sendBeacon API. This in turn has size limitations.
41
- keepalive: byteLength <= BEACON_BODY_SIZE_LIMIT,
49
+ keepalive: pendingBodySize <= BEACON_BODY_SIZE_LIMIT && pendingRequestCount <= 15,
42
50
  });
43
51
  // read the body so the connection can be closed
44
52
  response.text().catch(noop);
@@ -49,6 +57,10 @@ export async function send(path, body) {
49
57
  catch (error) {
50
58
  warn(`Error sending telemetry to ${endpoint.url}${path}:`, error);
51
59
  }
60
+ finally {
61
+ pendingBodySize -= byteLength;
62
+ pendingRequestCount--;
63
+ }
52
64
  }));
53
65
  }
54
66
  async function compressWithGzip(data) {
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { send } from "./fetch";
3
+ import { vars } from "../vars";
4
+ import * as utils from "../utils";
5
+ describe("fetch transport keepalive handling", () => {
6
+ let fetchMock;
7
+ let consoleWarnSpy;
8
+ beforeEach(() => {
9
+ // Reset vars
10
+ vars.endpoints = [
11
+ {
12
+ url: "https://api.example.com/",
13
+ authToken: "test-token",
14
+ },
15
+ ];
16
+ vars.enableTransportCompression = false;
17
+ // Mock fetch with proper response
18
+ fetchMock = vi.fn().mockResolvedValue({
19
+ ok: true,
20
+ status: 200,
21
+ statusText: "OK",
22
+ text: vi.fn().mockResolvedValue(""),
23
+ });
24
+ vi.spyOn(utils, "fetch").mockImplementation(fetchMock);
25
+ // Spy on warn to verify error handling
26
+ consoleWarnSpy = vi.spyOn(utils, "warn").mockImplementation(() => { });
27
+ });
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+ it("should set keepalive=true for small single request", async () => {
32
+ const smallBody = { data: "test" };
33
+ await send("/v1/traces", smallBody);
34
+ expect(fetchMock).toHaveBeenCalledOnce();
35
+ const fetchOptions = fetchMock.mock.calls[0][1];
36
+ expect(fetchOptions.keepalive).toBe(true);
37
+ });
38
+ it("should set keepalive=false when body size exceeds BEACON_BODY_SIZE_LIMIT", async () => {
39
+ // Create a large body that exceeds 60KB
40
+ const largeBody = { data: "x".repeat(65000) };
41
+ await send("/v1/traces", largeBody);
42
+ expect(fetchMock).toHaveBeenCalledOnce();
43
+ const fetchOptions = fetchMock.mock.calls[0][1];
44
+ expect(fetchOptions.keepalive).toBe(false);
45
+ });
46
+ it("should handle multiple parallel requests within limits", async () => {
47
+ const smallBody = { data: "test" };
48
+ // Send 3 requests in parallel
49
+ await Promise.all([send("/v1/traces", smallBody), send("/v1/traces", smallBody), send("/v1/traces", smallBody)]);
50
+ expect(fetchMock).toHaveBeenCalledTimes(3);
51
+ fetchMock.mock.calls.forEach((call) => {
52
+ expect(call[1].keepalive).toBe(true);
53
+ });
54
+ });
55
+ it("should set keepalive=false when pending request count exceeds 15", async () => {
56
+ // Create 16 endpoints to trigger the limit
57
+ vars.endpoints = Array.from({ length: 16 }, (_, i) => ({
58
+ url: `https://api${i}.example.com/`,
59
+ authToken: `token-${i}`,
60
+ }));
61
+ const smallBody = { data: "test" };
62
+ // Mock fetch to delay response to keep requests pending
63
+ let resolvers = [];
64
+ fetchMock.mockImplementation(() => new Promise((resolve) => {
65
+ resolvers.push(() => resolve({
66
+ ok: true,
67
+ status: 200,
68
+ statusText: "OK",
69
+ text: vi.fn().mockResolvedValue(""),
70
+ }));
71
+ }));
72
+ const sendPromise = send("/v1/traces", smallBody);
73
+ // Wait a bit for all requests to be initiated
74
+ await new Promise((resolve) => setTimeout(resolve, 10));
75
+ // Check that at least one request has keepalive=false
76
+ const keepaliveValues = fetchMock.mock.calls.map((call) => call[1].keepalive);
77
+ expect(keepaliveValues.some((v) => v === false)).toBe(true);
78
+ // Resolve all requests
79
+ resolvers.forEach((resolve) => resolve());
80
+ await sendPromise;
81
+ });
82
+ it("should correctly adjust pendingBodySize when request succeeds", async () => {
83
+ // Create bodies that are ~35KB each, so two together would exceed the 60KB limit
84
+ const mediumBody1 = { data: "x".repeat(35000) };
85
+ const mediumBody2 = { data: "y".repeat(35000) };
86
+ // First request: pendingBodySize starts at 0, so keepalive should be true
87
+ await send("/v1/traces", mediumBody1);
88
+ expect(fetchMock.mock.calls[0][1].keepalive).toBe(true);
89
+ // After first request completes, pendingBodySize should be back to 0
90
+ // Second request: pendingBodySize starts at 0 again, so keepalive should be true
91
+ await send("/v1/traces", mediumBody2);
92
+ expect(fetchMock.mock.calls[1][1].keepalive).toBe(true);
93
+ // This verifies that the pendingBodySize is properly decremented in the finally block
94
+ expect(fetchMock).toHaveBeenCalledTimes(2);
95
+ });
96
+ it("should correctly adjust pendingBodySize when request fails with network error", async () => {
97
+ // Create bodies that are ~35KB each, so two together would exceed the 60KB limit
98
+ const mediumBody = { data: "x".repeat(35000) };
99
+ // First request fails
100
+ fetchMock.mockRejectedValueOnce(new Error("Network error"));
101
+ // Second request succeeds
102
+ fetchMock.mockResolvedValueOnce({
103
+ ok: true,
104
+ status: 200,
105
+ statusText: "OK",
106
+ text: vi.fn().mockResolvedValue(""),
107
+ });
108
+ // First request: should have keepalive=true (pendingBodySize is 0)
109
+ await send("/v1/traces", mediumBody);
110
+ expect(fetchMock.mock.calls[0][1].keepalive).toBe(true);
111
+ // Even though first request failed, pendingBodySize should be decremented in finally block
112
+ // Second request: should also have keepalive=true (pendingBodySize is back to 0)
113
+ await send("/v1/traces", mediumBody);
114
+ expect(fetchMock.mock.calls[1][1].keepalive).toBe(true);
115
+ expect(fetchMock).toHaveBeenCalledTimes(2);
116
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Error sending telemetry"), expect.any(Error));
117
+ });
118
+ it("should correctly adjust pendingBodySize when response.text() fails", async () => {
119
+ // Create bodies that are ~35KB each, so two together would exceed the 60KB limit
120
+ const mediumBody = { data: "x".repeat(35000) };
121
+ // Mock response.text() to fail
122
+ fetchMock.mockResolvedValueOnce({
123
+ ok: true,
124
+ status: 200,
125
+ statusText: "OK",
126
+ text: vi.fn().mockRejectedValue(new Error("Failed to read body")),
127
+ });
128
+ // First request: should have keepalive=true (pendingBodySize is 0)
129
+ await send("/v1/traces", mediumBody);
130
+ expect(fetchMock.mock.calls[0][1].keepalive).toBe(true);
131
+ // Should still complete successfully and adjust pendingBodySize even though text() failed
132
+ expect(fetchMock).toHaveBeenCalledOnce();
133
+ // Next request should still work with keepalive=true (verifying pendingBodySize is 0)
134
+ fetchMock.mockResolvedValueOnce({
135
+ ok: true,
136
+ status: 200,
137
+ statusText: "OK",
138
+ text: vi.fn().mockResolvedValue(""),
139
+ });
140
+ await send("/v1/traces", mediumBody);
141
+ expect(fetchMock).toHaveBeenCalledTimes(2);
142
+ expect(fetchMock.mock.calls[1][1].keepalive).toBe(true);
143
+ });
144
+ it("should handle multiple parallel requests with failures", async () => {
145
+ vars.endpoints = [
146
+ { url: "https://api1.example.com/", authToken: "token1" },
147
+ { url: "https://api2.example.com/", authToken: "token2" },
148
+ { url: "https://api3.example.com/", authToken: "token3" },
149
+ ];
150
+ const body = { data: "test" };
151
+ // First endpoint fails, others succeed
152
+ fetchMock
153
+ .mockRejectedValueOnce(new Error("Network error"))
154
+ .mockResolvedValueOnce({
155
+ ok: true,
156
+ status: 200,
157
+ statusText: "OK",
158
+ text: vi.fn().mockResolvedValue(""),
159
+ })
160
+ .mockResolvedValueOnce({
161
+ ok: true,
162
+ status: 200,
163
+ statusText: "OK",
164
+ text: vi.fn().mockResolvedValue(""),
165
+ });
166
+ await send("/v1/traces", body);
167
+ expect(fetchMock).toHaveBeenCalledTimes(3);
168
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Error sending telemetry"), expect.any(Error));
169
+ // After all complete, counters should be reset
170
+ // Reset endpoints back to 1
171
+ vars.endpoints = [
172
+ {
173
+ url: "https://api.example.com/",
174
+ authToken: "test-token",
175
+ },
176
+ ];
177
+ fetchMock.mockResolvedValueOnce({
178
+ ok: true,
179
+ status: 200,
180
+ statusText: "OK",
181
+ text: vi.fn().mockResolvedValue(""),
182
+ });
183
+ await send("/v1/traces", body);
184
+ expect(fetchMock).toHaveBeenCalledTimes(4);
185
+ expect(fetchMock.mock.calls[3][1].keepalive).toBe(true);
186
+ });
187
+ it("should correctly handle mixed success and failure in parallel with size tracking", async () => {
188
+ vars.endpoints = [
189
+ { url: "https://api1.example.com/", authToken: "token1" },
190
+ { url: "https://api2.example.com/", authToken: "token2" },
191
+ ];
192
+ // Create a body that's about 30KB
193
+ const mediumBody = { data: "x".repeat(30000) };
194
+ // First endpoint fails, second succeeds
195
+ fetchMock.mockRejectedValueOnce(new Error("Network error")).mockResolvedValueOnce({
196
+ ok: true,
197
+ status: 200,
198
+ statusText: "OK",
199
+ text: vi.fn().mockResolvedValue(""),
200
+ });
201
+ await send("/v1/traces", mediumBody);
202
+ // Both requests should have been made
203
+ expect(fetchMock).toHaveBeenCalledTimes(2);
204
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Error sending telemetry"), expect.any(Error));
205
+ // After completion, pendingBodySize should be back to 0
206
+ // Reset to single endpoint for simpler test
207
+ vars.endpoints = [
208
+ {
209
+ url: "https://api.example.com/",
210
+ authToken: "test-token",
211
+ },
212
+ ];
213
+ fetchMock.mockResolvedValueOnce({
214
+ ok: true,
215
+ status: 200,
216
+ statusText: "OK",
217
+ text: vi.fn().mockResolvedValue(""),
218
+ });
219
+ await send("/v1/traces", { data: "test" });
220
+ expect(fetchMock.mock.calls[2][1].keepalive).toBe(true);
221
+ });
222
+ it("should handle requests with non-ok response status", async () => {
223
+ // Create bodies that are ~35KB each, so two together would exceed the 60KB limit
224
+ const mediumBody = { data: "x".repeat(35000) };
225
+ fetchMock.mockResolvedValueOnce({
226
+ ok: false,
227
+ status: 500,
228
+ statusText: "Internal Server Error",
229
+ text: vi.fn().mockResolvedValue(""),
230
+ });
231
+ // First request with error response
232
+ await send("/v1/traces", mediumBody);
233
+ expect(fetchMock).toHaveBeenCalledOnce();
234
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to send telemetry"));
235
+ // Verify pendingBodySize was adjusted correctly by sending another request
236
+ fetchMock.mockResolvedValueOnce({
237
+ ok: true,
238
+ status: 200,
239
+ statusText: "OK",
240
+ text: vi.fn().mockResolvedValue(""),
241
+ });
242
+ // Second request should have keepalive=true (verifying pendingBodySize was decremented)
243
+ await send("/v1/traces", mediumBody);
244
+ expect(fetchMock.mock.calls[1][1].keepalive).toBe(true);
245
+ });
246
+ it("should handle the exact boundary of BEACON_BODY_SIZE_LIMIT", async () => {
247
+ // Create a body that's just under 60000 bytes
248
+ const nearBoundaryBody = { data: "x".repeat(59970) }; // Accounting for JSON overhead
249
+ await send("/v1/traces", nearBoundaryBody);
250
+ expect(fetchMock).toHaveBeenCalledOnce();
251
+ const fetchOptions = fetchMock.mock.calls[0][1];
252
+ // Should be true since we're under the limit
253
+ expect(fetchOptions.keepalive).toBe(true);
254
+ });
255
+ it("should handle exactly 15 parallel requests", async () => {
256
+ vars.endpoints = Array.from({ length: 15 }, (_, i) => ({
257
+ url: `https://api${i}.example.com/`,
258
+ authToken: `token-${i}`,
259
+ }));
260
+ const smallBody = { data: "test" };
261
+ await send("/v1/traces", smallBody);
262
+ expect(fetchMock).toHaveBeenCalledTimes(15);
263
+ fetchMock.mock.calls.forEach((call) => {
264
+ expect(call[1].keepalive).toBe(true);
265
+ });
266
+ });
267
+ });