testdriverai 7.8.0 → 7.9.0-test.1

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.
Files changed (55) hide show
  1. package/agent/index.js +12 -0
  2. package/agent/lib/http.js +21 -3
  3. package/agent/lib/logger.js +15 -0
  4. package/agent/lib/provision-commands.js +176 -0
  5. package/agent/lib/sandbox.js +667 -118
  6. package/agent/lib/sdk.js +1 -20
  7. package/ai/skills/testdriver-find/SKILL.md +14 -20
  8. package/docs/_data/examples-manifest.json +46 -46
  9. package/docs/_scripts/extract-example-urls.js +67 -72
  10. package/docs/docs.json +2 -1
  11. package/docs/v7/examples/ai.mdx +1 -1
  12. package/docs/v7/examples/assert.mdx +1 -1
  13. package/docs/v7/examples/captcha-api.mdx +1 -1
  14. package/docs/v7/examples/chrome-extension.mdx +1 -1
  15. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  16. package/docs/v7/examples/element-not-found.mdx +1 -1
  17. package/docs/v7/examples/exec-output.mdx +1 -1
  18. package/docs/v7/examples/exec-pwsh.mdx +1 -1
  19. package/docs/v7/examples/focus-window.mdx +1 -1
  20. package/docs/v7/examples/hover-image.mdx +1 -1
  21. package/docs/v7/examples/hover-text.mdx +1 -1
  22. package/docs/v7/examples/installer.mdx +1 -1
  23. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  24. package/docs/v7/examples/match-image.mdx +1 -1
  25. package/docs/v7/examples/press-keys.mdx +1 -1
  26. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  27. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  28. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  29. package/docs/v7/examples/scroll.mdx +1 -1
  30. package/docs/v7/examples/type.mdx +1 -1
  31. package/docs/v7/examples/windows-installer.mdx +1 -1
  32. package/docs/v7/find.mdx +14 -20
  33. package/docs/v7/test-results-json.mdx +258 -0
  34. package/examples/scroll-keyboard.test.mjs +1 -1
  35. package/examples/scroll.test.mjs +1 -12
  36. package/interfaces/vitest-plugin.mjs +167 -51
  37. package/lib/core/Dashcam.js +16 -22
  38. package/lib/environments.json +8 -4
  39. package/lib/github-comment.mjs +58 -40
  40. package/lib/init-project.js +5 -67
  41. package/lib/resolve-channel.js +39 -10
  42. package/lib/sentry.js +47 -23
  43. package/lib/vitest/hooks.mjs +117 -20
  44. package/manual/exec-stream-logs.test.mjs +25 -0
  45. package/mcp-server/dist/server.mjs +28 -8
  46. package/mcp-server/src/server.ts +31 -8
  47. package/package.json +2 -1
  48. package/sdk.d.ts +4 -0
  49. package/sdk.js +42 -12
  50. package/setup/aws/install-dev-runner.sh +79 -0
  51. package/setup/aws/spawn-runner.sh +165 -0
  52. package/test-sentry-span.js +35 -0
  53. package/vitest.config.mjs +7 -3
  54. package/vitest.runner.config.mjs +33 -0
  55. package/docs/v7/_drafts/core.mdx +0 -458
@@ -49,7 +49,7 @@ function generateTestResultsTable(testCases, testRunUrl) {
49
49
 
50
50
  // Filter out skipped tests
51
51
  const nonSkippedTests = testCases.filter(test => test.status !== 'skipped');
52
-
52
+
53
53
  if (nonSkippedTests.length === 0) {
54
54
  return '_No test cases to display (all tests were skipped)_';
55
55
  }
@@ -62,12 +62,12 @@ function generateTestResultsTable(testCases, testRunUrl) {
62
62
  const name = test.testName || 'Unknown';
63
63
  const file = test.testFile || 'unknown';
64
64
  const duration = formatDuration(test.duration || 0);
65
-
65
+
66
66
  // Use test run context URL instead of direct replay URL
67
67
  let replay = '-';
68
68
  if (test.replayUrl) {
69
69
  const linkUrl = (test.id && testRunUrl) ? `${testRunUrl}/${test.id}` : test.replayUrl;
70
-
70
+
71
71
  // Extract replay ID and generate GIF URL
72
72
  const replayId = extractReplayId(test.replayUrl);
73
73
  if (replayId) {
@@ -97,28 +97,28 @@ function generateTestResultsTable(testCases, testRunUrl) {
97
97
  */
98
98
  function generateExceptionsSection(testCases, testRunUrl) {
99
99
  const failedTests = testCases.filter(t => t.status === 'failed' && t.errorMessage);
100
-
100
+
101
101
  if (failedTests.length === 0) {
102
102
  return '';
103
103
  }
104
104
 
105
105
  let section = '\n## 🔴 Failures\n\n';
106
-
106
+
107
107
  for (const test of failedTests) {
108
108
  section += `### ${test.testName}\n\n`;
109
109
  section += `**File:** \`${test.testFile}\`\n\n`;
110
-
110
+
111
111
  // Use test run context URL instead of direct replay URL
112
112
  if (test.id && testRunUrl) {
113
113
  section += `**📹 [Watch Replay](${testRunUrl}/${test.id})**\n\n`;
114
114
  } else if (test.replayUrl) {
115
115
  section += `**📹 [Watch Replay](${test.replayUrl})**\n\n`;
116
116
  }
117
-
117
+
118
118
  section += '```\n';
119
119
  section += test.errorMessage || 'Unknown error';
120
120
  section += '\n```\n\n';
121
-
121
+
122
122
  if (test.errorStack) {
123
123
  section += '<details>\n';
124
124
  section += '<summary>Stack Trace</summary>\n\n';
@@ -140,22 +140,22 @@ function generateExceptionsSection(testCases, testRunUrl) {
140
140
  */
141
141
  function generateReplaySection(testCases, testRunUrl) {
142
142
  const testsWithReplays = testCases.filter(t => t.replayUrl);
143
-
143
+
144
144
  if (testsWithReplays.length === 0) {
145
145
  return '';
146
146
  }
147
147
 
148
148
  let section = '\n## 🎥 Dashcam Replays\n\n';
149
-
149
+
150
150
  for (const test of testsWithReplays) {
151
151
  section += `### ${test.testName}\n\n`;
152
-
152
+
153
153
  // Determine the link URL - prefer test run context
154
154
  let linkUrl = test.replayUrl;
155
155
  if (test.id && testRunUrl) {
156
156
  linkUrl = `${testRunUrl}/${test.id}`;
157
157
  }
158
-
158
+
159
159
  // Extract replay ID from URL for GIF embed
160
160
  const replayId = extractReplayId(test.replayUrl);
161
161
  if (replayId) {
@@ -177,7 +177,7 @@ function generateReplaySection(testCases, testRunUrl) {
177
177
  */
178
178
  function extractReplayId(url) {
179
179
  if (!url) return null;
180
-
180
+
181
181
  // Match pattern: /replay/{id} or /replay/{id}?params
182
182
  const match = url.match(/\/replay\/([^?/#]+)/);
183
183
  return match ? match[1] : null;
@@ -190,7 +190,7 @@ function extractReplayId(url) {
190
190
  */
191
191
  function extractShareKey(url) {
192
192
  if (!url) return null;
193
-
193
+
194
194
  // Match pattern: ?share=KEY or &share=KEY
195
195
  const match = url.match(/[?&]share=([^&#]+)/);
196
196
  return match ? match[1] : null;
@@ -204,32 +204,50 @@ function extractShareKey(url) {
204
204
  */
205
205
  function getReplayGifUrl(replayUrl, replayId) {
206
206
  // Determine the API base URL based on the replay URL
207
+ // Replay URLs use console domains; GIF endpoints live on the corresponding API domain
207
208
  let apiBaseUrl;
208
-
209
+
209
210
  if (replayUrl.includes('app.dashcam.io')) {
210
211
  // Production dashcam uses Heroku API
211
212
  apiBaseUrl = 'https://testdriverai-v6-c96fc597be11.herokuapp.com';
212
- } else if (replayUrl.includes('console.testdriver.ai')) {
213
- // TestDriver console
214
- apiBaseUrl = 'https://api.testdriver.ai';
215
213
  } else if (replayUrl.includes('localhost')) {
216
214
  // Local development
217
215
  apiBaseUrl = 'http://localhost:1337';
218
216
  } else {
219
- // Default: try to extract base URL from replay URL
220
- const urlObj = new URL(replayUrl);
221
- apiBaseUrl = `${urlObj.protocol}//${urlObj.host}`;
217
+ // Map console URLs API URLs for all environments
218
+ // console-test.testdriver.ai api-test.testdriver.ai
219
+ // console-canary.testdriver.ai → api-canary.testdriver.ai
220
+ // console.testdriver.ai → api.testdriver.ai
221
+ const consoleEnvMatch = replayUrl.match(/https:\/\/console-(test|canary)\.testdriver\.ai/);
222
+ if (consoleEnvMatch) {
223
+ apiBaseUrl = `https://api-${consoleEnvMatch[1]}.testdriver.ai`;
224
+ } else if (replayUrl.includes('console.testdriver.ai')) {
225
+ apiBaseUrl = 'https://api.testdriver.ai';
226
+ }
227
+ // Fly.io: map web app → API app
228
+ // pr-123-web.fly.dev → pr-123-api.fly.dev
229
+ // td-test-web.fly.dev → td-test-api.fly.dev
230
+ else {
231
+ const flyWebMatch = replayUrl.match(/https:\/\/([\w-]+)-web\.fly\.dev/);
232
+ if (flyWebMatch) {
233
+ apiBaseUrl = `https://${flyWebMatch[1]}-api.fly.dev`;
234
+ } else {
235
+ // Fallback: extract base URL from replay URL as-is
236
+ const urlObj = new URL(replayUrl);
237
+ apiBaseUrl = `${urlObj.protocol}//${urlObj.host}`;
238
+ }
239
+ }
222
240
  }
223
-
241
+
224
242
  // Extract share key if present
225
243
  const shareKey = extractShareKey(replayUrl);
226
-
244
+
227
245
  // Build GIF URL with shareKey parameter
228
246
  let gifUrl = `${apiBaseUrl}/replay/${replayId}/gif`;
229
247
  if (shareKey) {
230
248
  gifUrl += `?shareKey=${shareKey}`;
231
249
  }
232
-
250
+
233
251
  return gifUrl;
234
252
  }
235
253
 
@@ -257,9 +275,9 @@ export function generateGitHubComment(testRunData, testCases = []) {
257
275
  // Header with overall status
258
276
  const statusEmoji = getStatusEmoji(status);
259
277
  const statusColor = status === 'passed' ? '🟢' : status === 'failed' ? '🔴' : '🟡';
260
-
278
+
261
279
  let comment = `# ${statusColor} TestDriver Test Results\n\n`;
262
-
280
+
263
281
  // Compact summary line
264
282
  comment += `**Status:** ${statusEmoji} ${status.toUpperCase()}`;
265
283
  comment += ` • **Duration:** ${formatDuration(duration)}`;
@@ -270,23 +288,23 @@ export function generateGitHubComment(testRunData, testCases = []) {
270
288
  comment += `, ${skippedTests} skipped`;
271
289
  }
272
290
  comment += `\n\n`;
273
-
291
+
274
292
  // Exceptions section (only if there are failures) - show first
275
293
  comment += generateExceptionsSection(testCases, testRunUrl);
276
-
294
+
277
295
  // Test results table (now includes embedded GIFs)
278
296
  comment += '## 📝 Test Results\n\n';
279
297
  comment += generateTestResultsTable(testCases, testRunUrl);
280
-
298
+
281
299
  // Link to full test run (below table)
282
300
  if (testRunUrl) {
283
301
  comment += `\n[📋 View Full Test Run](${testRunUrl})\n`;
284
302
  }
285
-
303
+
286
304
  // Footer
287
305
  comment += '\n---\n';
288
306
  comment += `<sub>Generated by [TestDriver](https://testdriver.ai) • Run ID: \`${runId}\`</sub>\n`;
289
-
307
+
290
308
  return comment;
291
309
  }
292
310
 
@@ -303,15 +321,15 @@ export function generateGitHubComment(testRunData, testCases = []) {
303
321
  */
304
322
  export async function postGitHubComment(options) {
305
323
  const { token, owner, repo, prNumber, commitSha, body } = options;
306
-
324
+
307
325
  if (!token) {
308
326
  throw new Error('GitHub token is required');
309
327
  }
310
-
328
+
311
329
  if (!owner || !repo) {
312
330
  throw new Error('Repository owner and name are required');
313
331
  }
314
-
332
+
315
333
  if (!prNumber && !commitSha) {
316
334
  throw new Error('Either prNumber or commitSha must be provided');
317
335
  }
@@ -351,7 +369,7 @@ export async function postGitHubComment(options) {
351
369
  */
352
370
  export async function updateGitHubComment(options) {
353
371
  const { token, owner, repo, commentId, body } = options;
354
-
372
+
355
373
  if (!token || !owner || !repo || !commentId) {
356
374
  throw new Error('Token, owner, repo, and commentId are required');
357
375
  }
@@ -364,7 +382,7 @@ export async function updateGitHubComment(options) {
364
382
  comment_id: commentId,
365
383
  body,
366
384
  });
367
-
385
+
368
386
  return response.data;
369
387
  }
370
388
 
@@ -379,7 +397,7 @@ export async function updateGitHubComment(options) {
379
397
  */
380
398
  export async function findExistingComment(options) {
381
399
  const { token, owner, repo, prNumber } = options;
382
-
400
+
383
401
  if (!token || !owner || !repo || !prNumber) {
384
402
  return null;
385
403
  }
@@ -410,11 +428,11 @@ export async function findExistingComment(options) {
410
428
  */
411
429
  export async function postOrUpdateTestResults(testRunData, testCases, githubOptions) {
412
430
  const commentBody = generateGitHubComment(testRunData, testCases);
413
-
431
+
414
432
  // Try to find and delete existing comment to keep it at the end
415
433
  if (githubOptions.prNumber) {
416
434
  const existingComment = await findExistingComment(githubOptions);
417
-
435
+
418
436
  if (existingComment) {
419
437
  // Delete the old comment
420
438
  const octokit = new Octokit({ auth: githubOptions.token });
@@ -425,7 +443,7 @@ export async function postOrUpdateTestResults(testRunData, testCases, githubOpti
425
443
  });
426
444
  }
427
445
  }
428
-
446
+
429
447
  // Always create a new comment (will be at the end of the thread)
430
448
  return await postGitHubComment({
431
449
  ...githubOptions,
@@ -14,66 +14,11 @@ function runInstall(cmd, args, cwd, label) {
14
14
  return new Promise((resolve, reject) => {
15
15
  const child = spawn(cmd, args, {
16
16
  cwd,
17
- stdio: ["ignore", "pipe", "pipe"],
17
+ stdio: ["ignore", "inherit", "inherit"],
18
18
  shell: process.platform === "win32",
19
19
  });
20
20
 
21
- const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
- const barWidth = 20;
23
- let frame = 0;
24
- let status = "resolving";
25
- let filled = 0;
26
-
27
- // Parse npm stderr for progress hints
28
- const handleData = (data) => {
29
- const text = data.toString();
30
- if (text.includes("idealTree")) {
31
- status = "resolving packages";
32
- filled = Math.max(filled, 3);
33
- } else if (text.includes("reify:")) {
34
- status = "installing";
35
- filled = Math.max(filled, 8);
36
- // Try to extract package name from reify output
37
- const match = text.match(/reify:([^\s:]+)/);
38
- if (match) {
39
- status = `installing ${match[1]}`;
40
- }
41
- } else if (text.includes("timing")) {
42
- filled = Math.max(filled, 14);
43
- status = "finalizing";
44
- } else if (text.includes("added")) {
45
- filled = barWidth;
46
- status = "done";
47
- }
48
- // Slowly increment to show activity
49
- if (filled < barWidth - 2) {
50
- filled = Math.min(filled + 1, barWidth - 2);
51
- }
52
- };
53
-
54
- child.stdout.on("data", handleData);
55
- child.stderr.on("data", handleData);
56
-
57
- const isTTY = process.stderr.isTTY;
58
-
59
- const interval = setInterval(() => {
60
- frame = (frame + 1) % spinnerFrames.length;
61
- const spinner = spinnerFrames[frame];
62
- const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
63
- const line = ` ${spinner} ${label} [${bar}] ${status}`;
64
- if (isTTY) {
65
- process.stderr.clearLine(0);
66
- process.stderr.cursorTo(0);
67
- process.stderr.write(line);
68
- }
69
- }, 80);
70
-
71
21
  child.on("close", (code) => {
72
- clearInterval(interval);
73
- if (isTTY) {
74
- process.stderr.clearLine(0);
75
- process.stderr.cursorTo(0);
76
- }
77
22
  if (code === 0) {
78
23
  resolve();
79
24
  } else {
@@ -82,11 +27,6 @@ function runInstall(cmd, args, cwd, label) {
82
27
  });
83
28
 
84
29
  child.on("error", (err) => {
85
- clearInterval(interval);
86
- if (isTTY) {
87
- process.stderr.clearLine(0);
88
- process.stderr.cursorTo(0);
89
- }
90
30
  reject(err);
91
31
  });
92
32
  });
@@ -384,11 +324,9 @@ jobs:
384
324
  [
385
325
  "--yes",
386
326
  "add-mcp",
327
+ "testdriverai",
328
+ "-n",
387
329
  "testdriver",
388
- "--command",
389
- "npx -p testdriverai testdriverai-mcp",
390
- "--env",
391
- "TD_API_KEY",
392
330
  ],
393
331
  {
394
332
  cwd: targetDir,
@@ -400,10 +338,10 @@ jobs:
400
338
  if (addMcpResult.status === 0) {
401
339
  progress("✓ MCP configured via add-mcp");
402
340
  } else if (addMcpResult.status !== null) {
403
- progress("⚠ MCP setup skipped or failed - you can run 'npx add-mcp testdriver' later");
341
+ progress("⚠ MCP setup skipped or failed - you can run 'npx add-mcp testdriverai' later");
404
342
  }
405
343
  } catch (err) {
406
- progress("⚠ Could not run add-mcp - you can run 'npx add-mcp testdriver' later");
344
+ progress("⚠ Could not run add-mcp - you can run 'npx add-mcp testdriverai' later");
407
345
  }
408
346
  }
409
347
 
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Resolves the active release channel and API URLs.
3
3
  *
4
+ * TD_CHANNEL: dev | test | canary | stable (which release channel)
5
+ * TD_ENV: dev | staging | production (which infrastructure tier)
6
+ *
4
7
  * Channel is derived from (in priority order):
5
8
  * 1. TD_CHANNEL env var (explicit override)
6
- * 2. TD_ENV env var (set by envs/<name>.env)
9
+ * 2. TD_ENV env var if it holds a legacy channel name (dev/test/canary/stable)
7
10
  * 3. SDK package.json version prerelease tag (e.g. "7.6.0-test.5" → "test")
8
- * 4. "latest" for clean semver versions (stable releases)
11
+ * 4. "stable" for clean semver versions
9
12
  */
10
13
 
11
14
  const semver = require("semver");
@@ -15,20 +18,20 @@ const CHANNELS = {
15
18
  dev: "http://localhost:1337",
16
19
  test: environments.test.apiRoot,
17
20
  canary: environments.canary.apiRoot,
18
- latest: environments.stable.apiRoot,
21
+ stable: environments.stable.apiRoot,
19
22
  };
20
23
 
24
+ const VALID_ENVS = new Set(["dev", "staging", "production"]);
25
+
21
26
  function resolveActiveChannel() {
22
27
  // 1. Explicit channel override
23
28
  if (process.env.TD_CHANNEL && CHANNELS[process.env.TD_CHANNEL]) {
24
29
  return process.env.TD_CHANNEL;
25
30
  }
26
31
 
27
- // 2. Environment name from env file (mapped: stable latest)
28
- if (process.env.TD_ENV) {
29
- const envName = process.env.TD_ENV;
30
- if (CHANNELS[envName]) return envName;
31
- if (envName === "stable") return "latest";
32
+ // 2. TD_ENV if it holds a legacy channel name, use it as channel
33
+ if (process.env.TD_ENV && CHANNELS[process.env.TD_ENV]) {
34
+ return process.env.TD_ENV;
32
35
  }
33
36
 
34
37
  // 3. Fallback: derive from package.json prerelease tag
@@ -38,9 +41,35 @@ function resolveActiveChannel() {
38
41
  return pre[0];
39
42
  }
40
43
 
41
- return "latest";
44
+ return "stable";
42
45
  }
43
46
 
44
47
  const active = resolveActiveChannel();
45
48
 
46
- module.exports = { active, channels: CHANNELS };
49
+ /**
50
+ * Returns the infrastructure environment (dev | staging | production).
51
+ * Reads from environments.json tdEnv mapping.
52
+ */
53
+ function resolveTdEnv() {
54
+ // If TD_ENV is already new-format, use it directly
55
+ if (process.env.TD_ENV && VALID_ENVS.has(process.env.TD_ENV)) {
56
+ return process.env.TD_ENV;
57
+ }
58
+ // Derive from channel
59
+ const entry = environments[active];
60
+ return entry ? entry.tdEnv : "production";
61
+ }
62
+
63
+ const tdEnv = resolveTdEnv();
64
+
65
+ /**
66
+ * Resolves the Sentry environment name.
67
+ * Uses the infrastructure tier (dev | staging | production).
68
+ */
69
+ function resolveSentryEnvironment() {
70
+ return tdEnv;
71
+ }
72
+
73
+ const sentryEnvironment = resolveSentryEnvironment();
74
+
75
+ module.exports = { active, channels: CHANNELS, sentryEnvironment, tdEnv };
package/lib/sentry.js CHANGED
@@ -14,6 +14,7 @@ const Sentry = require("@sentry/node");
14
14
  const crypto = require("crypto");
15
15
  const os = require("os");
16
16
  const { version } = require("../package.json");
17
+ const { sentryEnvironment } = require("./resolve-channel");
17
18
  const logger = require("../agent/lib/logger");
18
19
 
19
20
  // Store trace contexts per session so concurrent tests don't overwrite each other.
@@ -41,7 +42,7 @@ if (isEnabled()) {
41
42
  dsn:
42
43
  process.env.SENTRY_DSN ||
43
44
  "https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760",
44
- environment: "sdk",
45
+ environment: sentryEnvironment,
45
46
  release: version,
46
47
  sampleRate: 1.0,
47
48
  tracesSampleRate: 1.0, // Sample 20% of transactions for performance
@@ -53,6 +54,7 @@ if (isEnabled()) {
53
54
  platform: os.platform(),
54
55
  arch: os.arch(),
55
56
  nodeVersion: process.version,
57
+ runner: "sdk",
56
58
  },
57
59
  },
58
60
  // Filter out common non-errors and expected test failures
@@ -85,6 +87,11 @@ if (isEnabled()) {
85
87
  return null;
86
88
  }
87
89
 
90
+ // Filter out ElementNotFoundError - expected test outcome, not a crash
91
+ if (error && error.name === "ElementNotFoundError") {
92
+ return null;
93
+ }
94
+
88
95
  return event;
89
96
  },
90
97
  });
@@ -173,18 +180,41 @@ function captureMessage(message, level = "info") {
173
180
  }
174
181
 
175
182
  /**
176
- * Set the session trace context for distributed tracing
177
- * This links CLI errors/logs to the same trace as API calls
183
+ * Set the session trace context for distributed tracing.
184
+ * Creates a root span (sdk.session) that appears in the Sentry trace waterfall
185
+ * and sets propagation context so all outbound headers (HTTP and Ably) reference
186
+ * the same deterministic traceId = MD5(sessionId).
187
+ *
178
188
  * @param {string} sessionId - The session ID
179
189
  */
180
190
  function setSessionTraceContext(sessionId) {
181
191
  if (!isEnabled() || !sessionId) return;
182
192
 
183
- // Derive trace ID from session ID (same algorithm as API)
193
+ // Derive trace ID from session ID (same algorithm as API and Runner)
184
194
  const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
185
195
 
186
- // Store per-session trace context for concurrent safety
187
- _traceContexts.set(sessionId, { traceId, sessionId });
196
+ // Build a synthetic sentry-trace header to seed the trace with our deterministic ID
197
+ const spanId = crypto.randomBytes(8).toString("hex");
198
+ const sentryTraceHeader = `${traceId}-${spanId}-1`;
199
+ const baggageHeader = `sentry-trace_id=${traceId},sentry-sampled=true`;
200
+
201
+ // continueTrace sets the scope's propagation context so all subsequent
202
+ // getTraceData() calls return our traceId. startInactiveSpan creates a
203
+ // root transaction that will be visible in the Sentry trace waterfall.
204
+ let rootSpan = null;
205
+ Sentry.continueTrace(
206
+ { sentryTrace: sentryTraceHeader, baggage: baggageHeader },
207
+ () => {
208
+ rootSpan = Sentry.startInactiveSpan({
209
+ name: "sdk.session",
210
+ op: "session",
211
+ forceTransaction: true,
212
+ });
213
+ },
214
+ );
215
+
216
+ // Store per-session trace context (including root span for cleanup)
217
+ _traceContexts.set(sessionId, { traceId, sessionId, rootSpan });
188
218
 
189
219
  // Also update the module-level "latest" for backward compatibility
190
220
  currentTraceId = traceId;
@@ -193,28 +223,17 @@ function setSessionTraceContext(sessionId) {
193
223
  // Set as global tag so all events include it
194
224
  Sentry.setTag("session", sessionId);
195
225
  Sentry.setTag("trace_id", currentTraceId);
196
-
197
- // Try to set propagation context for trace linking (may not be available in all versions)
198
- try {
199
- const scope = Sentry.getCurrentScope();
200
- if (scope && typeof scope.setPropagationContext === "function") {
201
- scope.setPropagationContext({
202
- traceId: currentTraceId,
203
- spanId: currentTraceId.substring(0, 16),
204
- sampled: true,
205
- });
206
- }
207
- } catch (e) {
208
- // Ignore errors - propagation context may not be supported
209
- logger.log("Could not set propagation context:", e.message);
210
- }
211
226
  }
212
227
 
213
228
  /**
214
- * Clear the session trace context
229
+ * Clear the session trace context and end the root session span.
215
230
  */
216
231
  function clearSessionTraceContext(sessionId) {
217
232
  if (sessionId) {
233
+ const ctx = _traceContexts.get(sessionId);
234
+ if (ctx && ctx.rootSpan) {
235
+ try { ctx.rootSpan.end(); } catch (e) { /* ignore */ }
236
+ }
218
237
  _traceContexts.delete(sessionId);
219
238
  // If the cleared session was the "latest", pick another or null
220
239
  if (currentSessionId === sessionId) {
@@ -228,7 +247,12 @@ function clearSessionTraceContext(sessionId) {
228
247
  }
229
248
  }
230
249
  } else {
231
- // Clear all (backward compatibility)
250
+ // Clear all (backward compatibility) — end all root spans
251
+ for (const ctx of _traceContexts.values()) {
252
+ if (ctx.rootSpan) {
253
+ try { ctx.rootSpan.end(); } catch (e) { /* ignore */ }
254
+ }
255
+ }
232
256
  _traceContexts.clear();
233
257
  currentTraceId = null;
234
258
  currentSessionId = null;