telemetry-web.e2e.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { createServer, type Server } from 'node:http'
  2. import { once } from 'node:events'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { createRequire } from 'node:module'
  7. import { fileURLToPath } from 'node:url'
  8. import { execa } from 'execa'
  9. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  10. import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  11. /**
  12. * Keyless integration test for the web composition's telemetry row: boot the
  13. * REAL `dsh web` tree (source launch) against an in-test OTLP/HTTP collector
  14. * and a mock LLM server, drive one full turn over the /api carrier, then
  15. * SIGINT — the shutdown drain must deliver the whole ledger plus the ops
  16. * marker. Asserts what the collector actually received on the wire: OTLP
  17. * JSON structure, resource identity, both instrumentation scopes, the
  18. * session's event coverage in seq order, and the first-of-step chunk
  19. * projection. Package-level capture/backend behavior is covered by
  20. * session-telemetry-otel's own suites; this file pins the deployment wiring
  21. * (cordis.yml row + env overrides) end to end. Skips when the frontend dist
  22. * is not built (the web row fails loud without it).
  23. */
  24. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  25. const require = createRequire(new URL('../package.json', import.meta.url))
  26. function frontendDistPresent(): boolean {
  27. try {
  28. require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
  29. return true
  30. } catch {
  31. return false
  32. }
  33. }
  34. /** One decoded OTLP log record: flattened attributes plus the decoded body. */
  35. interface ReceivedRecord {
  36. scope: string
  37. severityText: string
  38. timeUnixNano: string
  39. attributes: Record<string, unknown>
  40. body: unknown
  41. }
  42. /** Decode an OTLP JSON AnyValue into plain JS for readable assertions. */
  43. function decodeAnyValue(value: Record<string, unknown>): unknown {
  44. if ('stringValue' in value) return value['stringValue']
  45. if ('intValue' in value) return Number(value['intValue'])
  46. if ('doubleValue' in value) return value['doubleValue']
  47. if ('boolValue' in value) return value['boolValue']
  48. if ('arrayValue' in value) {
  49. return ((value['arrayValue'] as { values?: Record<string, unknown>[] }).values ?? []).map(decodeAnyValue)
  50. }
  51. if ('kvlistValue' in value) {
  52. const entries = (value['kvlistValue'] as { values?: { key: string; value: Record<string, unknown> }[] }).values ?? []
  53. return Object.fromEntries(entries.map(entry => [entry.key, decodeAnyValue(entry.value)]))
  54. }
  55. return value
  56. }
  57. /** In-test OTLP/HTTP logs collector: captures every POST /v1/logs payload. */
  58. class TestCollector {
  59. readonly records: ReceivedRecord[] = []
  60. readonly badRequests: string[] = []
  61. private server: Server | undefined
  62. url = ''
  63. async start(): Promise<void> {
  64. this.server = createServer((request, response) => {
  65. const chunks: Buffer[] = []
  66. request.on('data', chunk => chunks.push(chunk as Buffer))
  67. request.on('end', () => {
  68. const body = Buffer.concat(chunks).toString()
  69. if (request.method !== 'POST' || request.url !== '/v1/logs'
  70. || request.headers['content-type']?.includes('application/json') !== true) {
  71. this.badRequests.push(`${request.method} ${request.url} ${request.headers['content-type']}`)
  72. response.writeHead(400).end()
  73. return
  74. }
  75. this.ingest(body)
  76. response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
  77. })
  78. })
  79. this.server.listen(0, '127.0.0.1')
  80. await once(this.server, 'listening')
  81. const address = this.server.address()
  82. if (address === null || typeof address === 'string') throw new Error('collector has no port')
  83. this.url = `http://127.0.0.1:${address.port}/v1/logs`
  84. }
  85. private ingest(body: string): void {
  86. const payload = JSON.parse(body) as {
  87. resourceLogs: {
  88. resource: { attributes: { key: string; value: Record<string, unknown> }[] }
  89. scopeLogs: {
  90. scope: { name: string }
  91. logRecords: {
  92. timeUnixNano?: string
  93. severityText?: string
  94. body?: Record<string, unknown>
  95. attributes?: { key: string; value: Record<string, unknown> }[]
  96. }[]
  97. }[]
  98. }[]
  99. }
  100. for (const resourceLog of payload.resourceLogs) {
  101. const resource = Object.fromEntries(
  102. resourceLog.resource.attributes.map(a => [a.key, decodeAnyValue(a.value)]))
  103. expect(resource['service.name']).toBe('deepseek-harness')
  104. expect(typeof resource['service.version']).toBe('string')
  105. for (const scopeLog of resourceLog.scopeLogs) {
  106. for (const record of scopeLog.logRecords) {
  107. expect(record.timeUnixNano).toBeTypeOf('string')
  108. expect(record.severityText).toBeTypeOf('string')
  109. this.records.push({
  110. scope: scopeLog.scope.name,
  111. severityText: record.severityText ?? '',
  112. timeUnixNano: record.timeUnixNano ?? '',
  113. attributes: Object.fromEntries((record.attributes ?? []).map(a => [a.key, decodeAnyValue(a.value)])),
  114. body: record.body === undefined ? undefined : decodeAnyValue(record.body),
  115. })
  116. }
  117. }
  118. }
  119. }
  120. async stop(): Promise<void> {
  121. this.server?.close()
  122. this.server?.closeAllConnections()
  123. }
  124. }
  125. /** Unary /api POST with the client-request envelope; unwraps the ok result. */
  126. async function rpc<T>(base: string, method: string, payload: unknown): Promise<T> {
  127. const response = await fetch(`${base}/api/${method}`, {
  128. method: 'POST',
  129. headers: { 'content-type': 'application/json' },
  130. body: JSON.stringify({ type: 'client-request', method, rpcId: `e2e-${method}-${Date.now()}`, payload }),
  131. })
  132. const parsed = await response.json() as { result: { ok: boolean; value?: T; error?: unknown } }
  133. if (!parsed.result.ok) throw new Error(`${method} failed: ${JSON.stringify(parsed.result.error)}`)
  134. return parsed.result.value as T
  135. }
  136. const PROMPT_TEXT = 'telemetry e2e probe: reply with one word'
  137. describe.skipIf(!frontendDistPresent())('web composition telemetry: OTLP collector receives the session ledger', () => {
  138. const collector = new TestCollector()
  139. let llm: MockLlmServer
  140. /** Narrow structural view of the subprocess: execa's per-call generics do not unify under exactOptionalPropertyTypes. */
  141. let web: {
  142. kill(signal: NodeJS.Signals): boolean
  143. settled: Promise<{ exitCode?: number | undefined; stderr?: unknown }>
  144. } | undefined
  145. let webBase = ''
  146. let dshHome = ''
  147. beforeAll(async () => {
  148. await collector.start()
  149. llm = await startMockLlmServer({ sequence: ['success'], repeatLast: true, successText: 'ok' })
  150. dshHome = mkdtempSync(join(tmpdir(), 'dsh-telemetry-e2e-'))
  151. const child = execa(process.execPath, ['--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', '--port', '0'], {
  152. cwd: repoRoot,
  153. reject: false,
  154. env: {
  155. DSH_HOME: dshHome,
  156. DSH_TELEMETRY_OTLP_URL: collector.url,
  157. DSH_TELEMETRY_DISABLED: '',
  158. DEEPSEEK_BASE_URL: llm.baseURL,
  159. DEEPSEEK_API_KEY: 'mock-key',
  160. },
  161. })
  162. web = { kill: signal => child.kill(signal), settled: child.then(result => result) }
  163. // The URL line is the boot-settled signal; tsx source boot on a cold
  164. // cache is slow, hence the generous window.
  165. webBase = await new Promise<string>((resolvePort, rejectPort) => {
  166. const timer = setTimeout(() => { rejectPort(new Error('dsh web printed no URL within the boot window')) }, 150_000)
  167. let seen = ''
  168. child.stdout?.on('data', (chunk: Buffer) => {
  169. seen += chunk.toString()
  170. const match = /dsh web: (http:\/\/127\.0\.0\.1:\d+)/.exec(seen)
  171. if (match !== null) {
  172. clearTimeout(timer)
  173. resolvePort(match[1] as string)
  174. }
  175. })
  176. void child.then((result) => {
  177. clearTimeout(timer)
  178. rejectPort(new Error(`dsh web exited before serving: ${String(result.stderr)}`))
  179. })
  180. })
  181. }, 180_000)
  182. afterAll(async () => {
  183. // Idempotent: SIGKILL after the test's own SIGINT-exit is a no-op.
  184. web?.kill('SIGKILL')
  185. await web?.settled
  186. await llm.close()
  187. await collector.stop()
  188. rmSync(dshHome, { recursive: true, force: true })
  189. })
  190. it('streams the full ledger and drains the ops marker on SIGINT', async () => {
  191. const { sessionId } = await rpc<{ sessionId: string }>(webBase, 'session.create', {})
  192. await rpc(webBase, 'session.prompt', {
  193. sessionId,
  194. mode: 'queue',
  195. content: [{ type: 'text', text: PROMPT_TEXT }],
  196. })
  197. // Wait for the turn to finish via the RPC face (telemetry batches on its
  198. // own 10s cadence, so the log — not the collector — is the completion signal).
  199. const deadline = Date.now() + 60_000
  200. let sawTurnEnd = false
  201. while (Date.now() < deadline && !sawTurnEnd) {
  202. const history = await rpc<{ events: { event: { type: string } }[] }>(
  203. webBase, 'session.history', { sessionId })
  204. sawTurnEnd = history.events.some(item => item.event.type === 'turn/end')
  205. if (!sawTurnEnd) await new Promise(resolveDelay => setTimeout(resolveDelay, 500))
  206. }
  207. expect(sawTurnEnd).toBe(true)
  208. // SIGINT → fiber dispose → coordinator emits shutdown markers → backend
  209. // drain. Everything must reach the collector without waiting a batch tick.
  210. web?.kill('SIGINT')
  211. const result = await web?.settled
  212. expect(result?.exitCode).toBe(130)
  213. expect(collector.badRequests).toEqual([])
  214. const mine = collector.records.filter(record => record.attributes['session.id'] === sessionId)
  215. const ledger = mine.filter(record => record.scope === '@deepseek-ai/dsh-session-telemetry-otel')
  216. const ops = mine.filter(record => record.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
  217. // Ledger coverage: the canonical turn shape arrived, each row carrying
  218. // the identity attributes and an integer seq.
  219. const types = ledger.map(record => record.attributes['event.type'])
  220. for (const expected of ['turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end']) {
  221. expect(types, expected).toContain(expected)
  222. }
  223. for (const record of ledger) {
  224. expect(Number.isInteger(record.attributes['event.seq'])).toBe(true)
  225. expect(record.severityText).toBeTruthy()
  226. }
  227. const seqs = ledger.map(record => record.attributes['event.seq'] as number)
  228. expect([...seqs].sort((a, b) => a - b)).toEqual(seqs)
  229. // Body fidelity: the exported copy carries the event data (no redaction
  230. // rule is mounted in this composition).
  231. const userMessage = ledger.find(record => record.attributes['event.type'] === 'user/message')
  232. expect(JSON.stringify(userMessage?.body)).toContain(PROMPT_TEXT)
  233. // Fixed chunk projection: at most the FIRST chunk of each (turn, step).
  234. const chunkKeys = ledger
  235. .filter(record => record.attributes['event.type'] === 'assistant/chunk')
  236. .map((record) => {
  237. const data = record.body as { turn: number; step: number }
  238. return `${data.turn}:${data.step}`
  239. })
  240. expect(new Set(chunkKeys).size).toBe(chunkKeys.length)
  241. // The drain proof: the session's clean-exit marker left the process
  242. // before it died.
  243. expect(ops.some(record => record.attributes['telemetry.op'] === 'shutdown')).toBe(true)
  244. }, 120_000)
  245. })