[CI] Add repository-local OTel tracing helpers (#52851)

Signed-off-by: khluu <khluu000@gmail.com> Co-authored-by: Codex <noreply@openai.com>

Kevin H. Luu committed Sep 1, 2026 at 02:36 UTC 504bb8b0c39dbde713a7344772bdb7005adbb214
5 files changed +1661 -1
.buildkite/image_build/image_build.sh
+48 -1
@@ -160,6 +160,42 @@ print_bake_config() {
160 (cd "$(dirname "${BAKE_CONFIG_FILE}")" && buildkite-agent artifact upload "$(basename "${BAKE_CONFIG_FILE}")")
161 }
162
163 +record_buildkit_trace() (
164 + local metadata_file="$1"
165 + local helper=".buildkite/scripts/ci-otel/ci_otel.py"
166 + local trace_file="${BUILD_TMP_DIR}/buildkit-trace.json"
167 + local build_ref record_id
168 +
169 + if [[ "${BUILDKITE:-}" != "true" || ! -f "${helper}" ]] ||
170 + ! command -v timeout >/dev/null 2>&1 ||
171 + ! command -v python3 >/dev/null 2>&1; then
172 + return 0
173 + fi
174 + build_ref="$(timeout 3s python3 "${helper}" build-ref "${metadata_file}" "${TARGET}" 2>/dev/null)" || {
175 + echo "vLLM CI OTel: BuildKit metadata unavailable; trace skipped" >&2
176 + return 0
177 + }
178 + record_id="${build_ref##*/}"
179 + if [[ -z "${record_id}" ]]; then
180 + return 0
181 + fi
182 +
183 + echo "--- :mag: Recording BuildKit trace"
184 + if ! timeout 20s docker buildx history trace "${record_id}" >"${trace_file}"; then
185 + echo "vLLM CI OTel: BuildKit history trace unavailable; upload skipped" >&2
186 + return 0
187 + fi
188 +
189 + export CI_INFRA_BUILDX_REF="${build_ref}"
190 + export CI_INFRA_OTEL_SPOOL_DIR="${BUILD_TMP_DIR}/otel-spans"
191 + timeout 5s python3 "${helper}" record-buildkit "${trace_file}" || {
192 + echo "vLLM CI OTel: BuildKit trace conversion skipped" >&2
193 + return 0
194 + }
195 + timeout 5s python3 "${helper}" flush ||
196 + echo "vLLM CI OTel: BuildKit trace upload skipped" >&2
197 +)
198 +
199 #################################
200 # Main Script #
201 #################################
@@ -269,7 +305,18 @@ export PARENT_COMMIT
305 print_bake_config
306
307 echo "--- :docker: Building ${TARGET}"
272 -docker --debug buildx bake -f "${VLLM_BAKE_FILE_PATH}" -f "${CI_HCL_PATH}" --progress plain "${TARGET}"
308 +BUILD_TMP_DIR="$(mktemp -d)"
309 +trap 'rm -rf -- "${BUILD_TMP_DIR}"' EXIT
310 +BUILD_METADATA_FILE="${BUILD_TMP_DIR}/build-metadata.json"
311 +BUILD_STATUS=0
312 +docker --debug buildx bake -f "${VLLM_BAKE_FILE_PATH}" -f "${CI_HCL_PATH}" \
313 + --progress plain --metadata-file "${BUILD_METADATA_FILE}" "${TARGET}" || BUILD_STATUS=$?
314 +
315 +record_buildkit_trace "${BUILD_METADATA_FILE}" || true
316 +
317 +if [[ "${BUILD_STATUS}" -ne 0 ]]; then
318 + exit "${BUILD_STATUS}"
319 +fi
320
321 echo "--- :white_check_mark: Build complete"
322
.buildkite/scripts/ci-otel/ci_otel.py new
+650
@@ -0,0 +1,650 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""Dependency-free OTLP exporter and pytest timing plugin for CI."""
5 +
6 +from __future__ import annotations
7 +
8 +import hashlib
9 +import json
10 +import os
11 +import secrets
12 +import struct
13 +import sys
14 +import time
15 +import urllib.request
16 +from collections.abc import Iterable
17 +from dataclasses import asdict, dataclass
18 +from pathlib import Path
19 +
20 +ENDPOINT = os.getenv("CI_INFRA_OTEL_ENDPOINT", "https://ci.vllm.ai/api/otel/v1/traces")
21 +AUDIENCE = os.getenv("CI_INFRA_OTEL_AUDIENCE", "https://ci.vllm.ai/api/otel")
22 +MAX_BATCH_SIZE = 2_000
23 +TEST_SPOOL_BATCH_SIZE = 100
24 +
25 +
26 +def _parse_dockerfile_step(name: str) -> tuple[str, int, int, str] | None:
27 + """Parse "[stage index/total] label" without regex."""
28 + if not name.startswith("["):
29 + return None
30 + close = name.find("]")
31 + if close == -1:
32 + return None
33 + header = name[1:close]
34 + label = name[close + 1 :].lstrip()
35 + if not label:
36 + return None
37 + parts = header.rsplit(None, 1)
38 + if len(parts) != 2:
39 + return None
40 + stage, step = parts
41 + if "/" not in step:
42 + return None
43 + index_str, total_str = step.split("/", 1)
44 + if not index_str.isdigit() or not total_str.isdigit():
45 + return None
46 + return stage, int(index_str), int(total_str), label
47 +
48 +
49 +@dataclass(frozen=True)
50 +class Span:
51 + trace_id: str
52 + span_id: str
53 + parent_span_id: str | None
54 + name: str
55 + start_ns: int
56 + end_ns: int
57 + attributes: dict[str, str | int | bool]
58 + status_code: int = 1
59 +
60 +
61 +def _varint(value: int) -> bytes:
62 + if value < 0:
63 + value += 1 << 64
64 + encoded = bytearray()
65 + while value > 0x7F:
66 + encoded.append((value & 0x7F) | 0x80)
67 + value >>= 7
68 + encoded.append(value)
69 + return bytes(encoded)
70 +
71 +
72 +def _field(number: int, wire_type: int) -> bytes:
73 + return _varint((number << 3) | wire_type)
74 +
75 +
76 +def _bytes_field(number: int, value: bytes) -> bytes:
77 + return _field(number, 2) + _varint(len(value)) + value
78 +
79 +
80 +def _string_field(number: int, value: str) -> bytes:
81 + return _bytes_field(number, value.encode())
82 +
83 +
84 +def _varint_field(number: int, value: int) -> bytes:
85 + return _field(number, 0) + _varint(value)
86 +
87 +
88 +def _fixed64_field(number: int, value: int) -> bytes:
89 + return _field(number, 1) + struct.pack("<Q", value)
90 +
91 +
92 +def _attribute(name: str, value: str | int | bool) -> bytes:
93 + if isinstance(value, bool):
94 + encoded_value = _varint_field(2, int(value))
95 + elif isinstance(value, int):
96 + encoded_value = _varint_field(3, value)
97 + else:
98 + encoded_value = _string_field(1, value)
99 + return _string_field(1, name) + _bytes_field(2, encoded_value)
100 +
101 +
102 +def _resource_attributes() -> dict[str, str | int | bool]:
103 + build_number = os.getenv("BUILDKITE_BUILD_NUMBER", "0")
104 + attributes: dict[str, str | int | bool] = {
105 + "service.name": "vllm-ci",
106 + "buildkite.organization.slug": os.getenv("BUILDKITE_ORGANIZATION_SLUG", ""),
107 + "buildkite.pipeline.slug": os.getenv("BUILDKITE_PIPELINE_SLUG", ""),
108 + "buildkite.build.id": os.getenv("BUILDKITE_BUILD_ID", ""),
109 + "buildkite.build.number": int(build_number) if build_number.isdigit() else 0,
110 + "buildkite.build.branch": os.getenv("BUILDKITE_BRANCH", ""),
111 + "buildkite.build.commit": os.getenv("BUILDKITE_COMMIT", ""),
112 + "buildkite.job.id": os.getenv("BUILDKITE_JOB_ID", ""),
113 + "buildkite.job.label": os.getenv("BUILDKITE_LABEL", ""),
114 + "buildkite.agent.queue": os.getenv("BUILDKITE_AGENT_META_DATA_QUEUE", ""),
115 + }
116 + return {name: value for name, value in attributes.items() if value != ""}
117 +
118 +
119 +def _encode_span(span: Span) -> bytes:
120 + encoded = b"".join(
121 + (
122 + _bytes_field(1, bytes.fromhex(span.trace_id)),
123 + _bytes_field(2, bytes.fromhex(span.span_id)),
124 + _bytes_field(4, bytes.fromhex(span.parent_span_id))
125 + if span.parent_span_id
126 + else b"",
127 + _string_field(5, span.name),
128 + _varint_field(6, 1),
129 + _fixed64_field(7, span.start_ns),
130 + _fixed64_field(8, span.end_ns),
131 + )
132 + )
133 + attributes = dict(span.attributes)
134 + if os.getenv("BUILDKITE_BUILD_URL") and os.getenv("BUILDKITE_JOB_ID"):
135 + attributes["buildkite.job.web_url"] = (
136 + f"{os.environ['BUILDKITE_BUILD_URL']}#{os.environ['BUILDKITE_JOB_ID']}"
137 + )
138 + for name, value in attributes.items():
139 + encoded += _bytes_field(9, _attribute(name, value))
140 + return encoded + _bytes_field(15, _varint_field(3, span.status_code))
141 +
142 +
143 +def encode_request(spans: Iterable[Span]) -> bytes:
144 + resource = b"".join(
145 + _bytes_field(1, _attribute(name, value))
146 + for name, value in _resource_attributes().items()
147 + )
148 + scope_spans = _bytes_field(1, _string_field(1, "vllm.ci"))
149 + for span in spans:
150 + scope_spans += _bytes_field(2, _encode_span(span))
151 + return _bytes_field(1, _bytes_field(1, resource) + _bytes_field(2, scope_spans))
152 +
153 +
154 +def new_context() -> tuple[str, str, str | None]:
155 + parts = os.getenv("TRACEPARENT", "").lower().split("-")
156 + valid = (
157 + len(parts) == 4
158 + and [len(part) for part in parts] == [2, 32, 16, 2]
159 + and all(all(char in "0123456789abcdef" for char in part) for part in parts)
160 + and parts[1] != "0" * 32
161 + and parts[2] != "0" * 16
162 + )
163 + if valid:
164 + return parts[1], secrets.token_hex(8), parts[2]
165 + build_id = os.getenv("BUILDKITE_BUILD_ID", "local")
166 + trace_id = hashlib.sha256(build_id.encode()).hexdigest()[:32]
167 + return trace_id, secrets.token_hex(8), None
168 +
169 +
170 +def _spool_dir() -> Path | None:
171 + value = os.getenv("CI_INFRA_OTEL_SPOOL_DIR")
172 + return Path(value) if value else None
173 +
174 +
175 +def record_spans(spans: Iterable[Span]) -> bool:
176 + try:
177 + spool_dir = _spool_dir()
178 + records = [json.dumps(asdict(span), separators=(",", ":")) for span in spans]
179 + if spool_dir is None or not records:
180 + return False
181 + spool_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
182 + with (spool_dir / f"spans-{os.getpid()}.jsonl").open(
183 + "a", encoding="utf-8"
184 + ) as output:
185 + output.write("\n".join(records) + "\n")
186 + return True
187 + except Exception as error:
188 + print(f"CI timing spool skipped: {error}", file=sys.stderr)
189 + return False
190 +
191 +
192 +def _read_spool() -> tuple[list[Span], list[Path]]:
193 + spool_dir = _spool_dir()
194 + if spool_dir is None or not spool_dir.is_dir():
195 + return [], []
196 + spans: list[Span] = []
197 + loaded_files: list[Path] = []
198 + for spool_file in sorted(spool_dir.glob("spans-*.jsonl")):
199 + try:
200 + spans.extend(
201 + Span(**json.loads(record))
202 + for record in spool_file.read_text(encoding="utf-8").splitlines()
203 + if record
204 + )
205 + loaded_files.append(spool_file)
206 + except Exception as error:
207 + print(
208 + f"CI timing spool ignored {spool_file.name}: {error}", file=sys.stderr
209 + )
210 + return spans, loaded_files
211 +
212 +
213 +def load_spans() -> list[Span]:
214 + return _read_spool()[0]
215 +
216 +
217 +def buildx_ref(metadata_path: str, target: str) -> str:
218 + """Read the exact Buildx history reference emitted by bake."""
219 + metadata = json.loads(Path(metadata_path).read_text(encoding="utf-8"))
220 + target_metadata = metadata.get(target, metadata)
221 + reference = target_metadata.get("buildx.build.ref")
222 + if not isinstance(reference, str) or not reference:
223 + raise ValueError(f"Buildx metadata has no history reference for {target}")
224 + return reference
225 +
226 +
227 +def _buildkit_tags(span: dict) -> dict[str, str | int | bool]:
228 + tags: dict[str, str | int | bool] = {}
229 + for tag in span.get("tags", []):
230 + key = tag.get("key")
231 + value = tag.get("value")
232 + if isinstance(key, str) and isinstance(value, (str, int, bool)):
233 + tags[key] = value
234 + return tags
235 +
236 +
237 +def _buildkit_parent_ids(span: dict) -> list[str]:
238 + return [
239 + reference["spanID"]
240 + for reference in span.get("references", [])
241 + if reference.get("refType") == "CHILD_OF"
242 + and isinstance(reference.get("spanID"), str)
243 + ]
244 +
245 +
246 +def _buildkit_span_id(trace_id: str, value: str) -> str:
247 + return hashlib.sha256(f"{trace_id}:{value}".encode()).hexdigest()[:16]
248 +
249 +
250 +def buildkit_spans(payload: dict) -> list[Span]:
251 + """Normalize Buildx's Jaeger JSON into dashboard-oriented OTLP spans."""
252 + trace_id, _, _ = new_context()
253 + traces = payload.get("data", [])
254 + native_trace_id = (
255 + traces[0].get("traceID", "buildkit")
256 + if traces and isinstance(traces[0], dict)
257 + else "buildkit"
258 + )
259 + span_scope = (
260 + os.getenv("CI_INFRA_BUILDX_REF")
261 + or os.getenv("BUILDKITE_JOB_ID")
262 + or str(native_trace_id)
263 + )
264 + steps: list[tuple[str, int, int, str, dict, int, int, int]] = []
265 +
266 + for trace in traces:
267 + raw_spans = [
268 + span
269 + for span in trace.get("spans", [])
270 + if isinstance(span, dict) and isinstance(span.get("spanID"), str)
271 + ]
272 + spans_by_id = {span["spanID"]: span for span in raw_spans}
273 + children: dict[str, list[dict]] = {}
274 + for span in raw_spans:
275 + for parent_id in _buildkit_parent_ids(span):
276 + if parent_id in spans_by_id:
277 + children.setdefault(parent_id, []).append(span)
278 +
279 + def envelope(
280 + span: dict, child_spans: dict[str, list[dict]] = children
281 + ) -> tuple[int, int]:
282 + start_us = int(span["startTime"])
283 + end_us = start_us + int(span["duration"])
284 + pending = list(child_spans.get(span["spanID"], []))
285 + seen: set[str] = set()
286 + while pending:
287 + child = pending.pop()
288 + child_id = child["spanID"]
289 + if child_id in seen:
290 + continue
291 + seen.add(child_id)
292 + child_start = int(child["startTime"])
293 + start_us = min(start_us, child_start)
294 + end_us = max(end_us, child_start + int(child["duration"]))
295 + pending.extend(child_spans.get(child_id, []))
296 + return start_us * 1_000, end_us * 1_000
297 +
298 + selected: dict[str, tuple[dict, int, int]] = {}
299 + for span in raw_spans:
300 + name = span.get("operationName")
301 + if not isinstance(name, str) or name.startswith("cache request: "):
302 + continue
303 + step = _parse_dockerfile_step(name)
304 + is_internal = name.startswith("[internal] ")
305 + is_export = name.startswith(("exporting to ", "export to "))
306 + if not (step or is_internal or is_export):
307 + continue
308 + try:
309 + start_ns, end_ns = envelope(span)
310 + except (KeyError, TypeError, ValueError):
311 + continue
312 + vertex = _buildkit_tags(span).get("vertex")
313 + key = str(vertex) if vertex else f"{name}:{span['spanID']}"
314 + previous = selected.get(key)
315 + if previous is None or end_ns - start_ns > previous[2] - previous[1]:
316 + selected[key] = (span, start_ns, end_ns)
317 +
318 + for span, start_ns, end_ns in selected.values():
319 + name = span["operationName"]
320 + step = _parse_dockerfile_step(name)
321 + tags = _buildkit_tags(span)
322 + failed = (
323 + tags.get("error") is True or tags.get("otel.status_code") == "ERROR"
324 + )
325 + if step:
326 + stage, step_index, step_total, label = step
327 + else:
328 + stage = "BuildKit internals"
329 + label = name.removeprefix("[internal] ")
330 + step_index = step_total = 0
331 + steps.append(
332 + (
333 + stage,
334 + step_index,
335 + step_total,
336 + label,
337 + tags,
338 + start_ns,
339 + end_ns,
340 + 2 if failed else 1,
341 + )
342 + )
343 +
344 + output: list[Span] = []
345 + build_ref = os.getenv("CI_INFRA_BUILDX_REF", "")
346 + for stage in dict.fromkeys(
347 + step[0] for step in steps if step[0] != "BuildKit internals"
348 + ):
349 + stage_steps = sorted(
350 + (step for step in steps if step[0] == stage), key=lambda step: step[5]
351 + )
352 + stage_id = _buildkit_span_id(trace_id, f"{span_scope}:stage:{stage}")
353 + stage_start = min(step[5] for step in stage_steps)
354 + stage_end = max(step[6] for step in stage_steps)
355 + stage_failed = any(step[7] == 2 for step in stage_steps)
356 + stage_attributes: dict[str, str | int | bool] = {
357 + "ci.span.kind": "docker-stage",
358 + "docker.stage": stage,
359 + "docker.step.count": len(stage_steps),
360 + }
361 + if build_ref:
362 + stage_attributes["docker.build.ref"] = build_ref
363 + output.append(
364 + Span(
365 + trace_id=trace_id,
366 + span_id=stage_id,
367 + parent_span_id=None,
368 + name="docker.stage",
369 + start_ns=stage_start,
370 + end_ns=stage_end,
371 + attributes=stage_attributes,
372 + status_code=2 if stage_failed else 1,
373 + )
374 + )
375 + for (
376 + stage_name,
377 + index,
378 + total,
379 + label,
380 + tags,
381 + start_ns,
382 + end_ns,
383 + status,
384 + ) in stage_steps:
385 + vertex = str(tags.get("vertex", ""))
386 + attributes: dict[str, str | int | bool] = {
387 + "ci.span.kind": "docker-instruction",
388 + "docker.stage": stage_name,
389 + "docker.step.label": label,
390 + }
391 + if index:
392 + attributes["docker.step.index"] = index
393 + attributes["docker.step.total"] = total
394 + attributes["docker.instruction"] = label.split(None, 1)[0]
395 + if vertex:
396 + attributes["docker.vertex"] = vertex
397 + output.append(
398 + Span(
399 + trace_id=trace_id,
400 + span_id=_buildkit_span_id(
401 + trace_id,
402 + f"{span_scope}:step:{stage_name}:{index}:{vertex}:{label}",
403 + ),
404 + parent_span_id=stage_id,
405 + name="docker.instruction",
406 + start_ns=start_ns,
407 + end_ns=end_ns,
408 + attributes=attributes,
409 + status_code=status,
410 + )
411 + )
412 + for stage_name, index, total, label, tags, start_ns, end_ns, status in (
413 + step for step in steps if step[0] == "BuildKit internals"
414 + ):
415 + vertex = str(tags.get("vertex", ""))
416 + attributes: dict[str, str | int | bool] = {
417 + "ci.span.kind": "docker-internal",
418 + "docker.stage": stage_name,
419 + "docker.step.label": label,
420 + }
421 + if vertex:
422 + attributes["docker.vertex"] = vertex
423 + output.append(
424 + Span(
425 + trace_id=trace_id,
426 + span_id=_buildkit_span_id(
427 + trace_id, f"{span_scope}:internal:{vertex}:{label}"
428 + ),
429 + parent_span_id=None,
430 + name="docker.internal",
431 + start_ns=start_ns,
432 + end_ns=end_ns,
433 + attributes=attributes,
434 + status_code=status,
435 + )
436 + )
437 + return output
438 +
439 +
440 +def record_buildkit_trace(trace_path: str) -> bool:
441 + payload = json.loads(Path(trace_path).read_text(encoding="utf-8"))
442 + return record_spans(buildkit_spans(payload))
443 +
444 +
445 +def _remaining_seconds(deadline: float) -> float:
446 + remaining = deadline - time.monotonic()
447 + if remaining <= 0:
448 + raise TimeoutError("CI timing upload deadline exceeded")
449 + return remaining
450 +
451 +
452 +def _oidc_token(deadline: float) -> str:
453 + access_token = os.getenv("BUILDKITE_AGENT_ACCESS_TOKEN", "")
454 + job_id = os.getenv("BUILDKITE_JOB_ID", "")
455 + if not access_token or not job_id:
456 + raise RuntimeError("Buildkite job credentials are unavailable")
457 + endpoint = os.getenv(
458 + "BUILDKITE_AGENT_ENDPOINT", "https://agent.buildkite.com/v3"
459 + ).rstrip("/")
460 + request = urllib.request.Request(
461 + f"{endpoint}/jobs/{job_id}/oidc/tokens",
462 + data=json.dumps({"audience": AUDIENCE, "lifetime": 300}).encode(),
463 + method="POST",
464 + headers={
465 + "Authorization": f"Token {access_token}",
466 + "Content-Type": "application/json",
467 + "User-Agent": "vllm-ci-otel/1",
468 + },
469 + )
470 + with urllib.request.urlopen(
471 + request, timeout=_remaining_seconds(deadline)
472 + ) as response:
473 + token = json.loads(response.read()).get("token")
474 + if not isinstance(token, str) or not token:
475 + raise RuntimeError("Buildkite OIDC response did not contain a token")
476 + return token
477 +
478 +
479 +def export_spans(spans: list[Span], timeout_seconds: float = 3.0) -> bool:
480 + if not spans or os.getenv("BUILDKITE") != "true":
481 + return False
482 + try:
483 + deadline = time.monotonic() + max(timeout_seconds, 0.1)
484 + token = _oidc_token(deadline)
485 + for offset in range(0, len(spans), MAX_BATCH_SIZE):
486 + request = urllib.request.Request(
487 + ENDPOINT,
488 + data=encode_request(spans[offset : offset + MAX_BATCH_SIZE]),
489 + method="POST",
490 + headers={
491 + "Authorization": f"Bearer {token}",
492 + "Content-Type": "application/x-protobuf",
493 + "User-Agent": "vllm-ci-otel/1",
494 + },
495 + )
496 + with urllib.request.urlopen(
497 + request, timeout=_remaining_seconds(deadline)
498 + ) as response:
499 + if response.status != 200:
500 + raise RuntimeError(f"OTLP endpoint returned {response.status}")
501 + return True
502 + except Exception as error:
503 + print(f"CI timing upload skipped: {error}", file=sys.stderr)
504 + return False
505 +
506 +
507 +def flush_spans(timeout_seconds: float = 3.0) -> bool:
508 + spans, spool_files = _read_spool()
509 + if not spool_files:
510 + return True
511 + if not spans or not export_spans(spans, timeout_seconds):
512 + return False
513 + for spool_file in spool_files:
514 + try:
515 + spool_file.unlink()
516 + except FileNotFoundError:
517 + pass
518 + except Exception as error:
519 + print(f"CI timing spool cleanup skipped: {error}", file=sys.stderr)
520 + return True
521 +
522 +
523 +_test_runs: dict[str, tuple[int, str]] = {}
524 +_test_spans: list[Span] = []
525 +
526 +
527 +def _spool_test_spans() -> None:
528 + if not _test_spans:
529 + return
530 + record_spans(_test_spans)
531 + _test_spans.clear()
532 +
533 +
534 +def _test_span(nodeid: str, start_ns: int, outcome: str) -> Span:
535 + return Span(
536 + trace_id=os.environ["CI_INFRA_TRACE_ID"],
537 + span_id=secrets.token_hex(8),
538 + parent_span_id=os.environ["CI_INFRA_COMMAND_SPAN_ID"],
539 + name="pytest.test",
540 + start_ns=start_ns,
541 + end_ns=time.time_ns(),
542 + attributes={
543 + "ci.span.kind": "test",
544 + "test.nodeid": nodeid,
545 + "test.file": nodeid.split("::", 1)[0],
546 + "test.outcome": outcome,
547 + },
548 + status_code=2 if outcome == "failed" else 1,
549 + )
550 +
551 +
552 +def pytest_runtest_logstart(nodeid: str, location: tuple[str, int | None, str]):
553 + try:
554 + if os.getenv("CI_INFRA_TRACE_ID") and os.getenv("CI_INFRA_COMMAND_SPAN_ID"):
555 + _test_runs[nodeid] = (time.time_ns(), "unknown")
556 + except Exception:
557 + pass
558 +
559 +
560 +def pytest_runtest_logreport(report):
561 + try:
562 + if report.nodeid not in _test_runs:
563 + return
564 + start_ns, outcome = _test_runs[report.nodeid]
565 + if report.failed:
566 + outcome = "failed"
567 + elif report.skipped and outcome != "failed":
568 + outcome = "skipped"
569 + elif report.when == "call" and outcome == "unknown":
570 + outcome = "passed"
571 + _test_runs[report.nodeid] = (start_ns, outcome)
572 + except Exception:
573 + pass
574 +
575 +
576 +def pytest_runtest_logfinish(nodeid: str, location: tuple[str, int | None, str]):
577 + try:
578 + run = _test_runs.pop(nodeid, None)
579 + if run:
580 + _test_spans.append(_test_span(nodeid, *run))
581 + if len(_test_spans) >= TEST_SPOOL_BATCH_SIZE:
582 + _spool_test_spans()
583 + except Exception:
584 + pass
585 +
586 +
587 +def pytest_sessionfinish(session, exitstatus: int):
588 + try:
589 + for nodeid, run in list(_test_runs.items()):
590 + _test_spans.append(_test_span(nodeid, *run))
591 + _test_runs.clear()
592 + _spool_test_spans()
593 + except Exception:
594 + pass
595 +
596 +
597 +def _record_command(arguments: list[str]) -> None:
598 + trace_id, span_id, parent_span_id, start_ns, index, exit_code, label = arguments
599 + exit_status = int(exit_code)
600 + record_spans(
601 + [
602 + Span(
603 + trace_id=trace_id,
604 + span_id=span_id,
605 + parent_span_id=None if parent_span_id == "-" else parent_span_id,
606 + name="ci.command",
607 + start_ns=int(start_ns),
608 + end_ns=time.time_ns(),
609 + attributes={
610 + "ci.span.kind": "command",
611 + "ci.command.index": int(index),
612 + "ci.command.label": label,
613 + "process.exit.code": exit_status,
614 + },
615 + status_code=1 if exit_status == 0 else 2,
616 + )
617 + ]
618 + )
619 +
620 +
621 +def main(arguments: list[str] | None = None) -> int:
622 + arguments = sys.argv[1:] if arguments is None else arguments
623 + try:
624 + if arguments == ["new-context"]:
625 + trace_id, span_id, parent_span_id = new_context()
626 + print(trace_id, span_id, parent_span_id or "-", time.time_ns())
627 + return 0
628 + if arguments == ["flush"]:
629 + return 0 if flush_spans() else 1
630 + if len(arguments) == 3 and arguments[0] == "build-ref":
631 + print(buildx_ref(arguments[1], arguments[2]))
632 + return 0
633 + if len(arguments) == 2 and arguments[0] == "record-buildkit":
634 + return 0 if record_buildkit_trace(arguments[1]) else 1
635 + if len(arguments) == 8 and arguments[0] == "record-command":
636 + _record_command(arguments[1:])
637 + return 0
638 + print(
639 + "usage: ci_otel.py {new-context|flush|build-ref ...|"
640 + "record-buildkit ...|record-command ...}",
641 + file=sys.stderr,
642 + )
643 + return 2
644 + except Exception as error:
645 + print(f"CI timing helper failed: {error}", file=sys.stderr)
646 + return 1
647 +
648 +
649 +if __name__ == "__main__":
650 + raise SystemExit(main())
.buildkite/scripts/ci-otel/ci_otel.sh new
+133
@@ -0,0 +1,133 @@
1 +#!/bin/sh
2 +
3 +[ "${_CI_INFRA_OTEL_INITIALIZED:-0}" = "1" ] && return 0
4 +
5 +# No-op fallbacks keep every generated wrapper safe when setup is unavailable.
6 +ci_otel_start() { :; }
7 +ci_otel_finish() { :; }
8 +ci_otel_run() { shift 2; env "$@"; return $?; }
9 +
10 +if [ -z "${CI_INFRA_OTEL_DIR:-}" ]; then
11 + echo "vLLM CI OTel: helper directory is unset; tracing disabled" >&2 || :
12 + return 0
13 +fi
14 +export CI_INFRA_OTEL_DIR
15 +
16 +_CI_INFRA_OTEL_OWNS_RUNTIME=0
17 +if [ -z "${CI_INFRA_OTEL_RUNTIME_DIR:-}" ]; then
18 + CI_INFRA_OTEL_RUNTIME_DIR="$(mktemp -d 2>/dev/null || :)"
19 + [ -n "${CI_INFRA_OTEL_RUNTIME_DIR}" ] && _CI_INFRA_OTEL_OWNS_RUNTIME=1
20 +fi
21 +CI_INFRA_OTEL_SPOOL_DIR="${CI_INFRA_OTEL_SPOOL_DIR:-${CI_INFRA_OTEL_RUNTIME_DIR}/spans}"
22 +export CI_INFRA_OTEL_RUNTIME_DIR CI_INFRA_OTEL_SPOOL_DIR
23 +
24 +_CI_INFRA_OTEL_PYTHON="$(command -v python3 2>/dev/null || :)"
25 +if [ -z "${CI_INFRA_OTEL_RUNTIME_DIR}" ] ||
26 + [ -z "${_CI_INFRA_OTEL_PYTHON}" ] ||
27 + [ ! -f "${CI_INFRA_OTEL_DIR}/ci_otel.py" ] ||
28 + ! mkdir -p "${CI_INFRA_OTEL_SPOOL_DIR}" ||
29 + ! PYTHONPATH="${CI_INFRA_OTEL_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
30 + "${_CI_INFRA_OTEL_PYTHON}" -c "import ci_otel" >/dev/null 2>&1; then
31 + echo "vLLM CI OTel: tracing unavailable; test command will run normally" >&2 || :
32 + if [ "${_CI_INFRA_OTEL_OWNS_RUNTIME}" = "1" ]; then
33 + rm -rf -- "${CI_INFRA_OTEL_RUNTIME_DIR}" || :
34 + fi
35 + return 0
36 +fi
37 +
38 +_CI_INFRA_OTEL_SHIM_DIR="${CI_INFRA_OTEL_RUNTIME_DIR}/bin"
39 +if mkdir -p "${_CI_INFRA_OTEL_SHIM_DIR}"; then
40 + _CI_INFRA_OTEL_SHIM="${_CI_INFRA_OTEL_SHIM_DIR}/pytest"
41 + if [ ! -e "${_CI_INFRA_OTEL_SHIM}" ] && [ ! -L "${_CI_INFRA_OTEL_SHIM}" ]; then
42 + ln -s "${CI_INFRA_OTEL_DIR}/ci_pytest.sh" "${_CI_INFRA_OTEL_SHIM}" || :
43 + fi
44 + if [ -L "${_CI_INFRA_OTEL_SHIM}" ] &&
45 + [ "$(readlink "${_CI_INFRA_OTEL_SHIM}" 2>/dev/null || :)" = \
46 + "${CI_INFRA_OTEL_DIR}/ci_pytest.sh" ]; then
47 + case ":${CI_INFRA_OTEL_SHIM_PATHS:-}:" in
48 + *":${_CI_INFRA_OTEL_SHIM_DIR}:"*) ;;
49 + *)
50 + CI_INFRA_OTEL_SHIM_PATHS="${CI_INFRA_OTEL_SHIM_PATHS:+${CI_INFRA_OTEL_SHIM_PATHS}:}${_CI_INFRA_OTEL_SHIM_DIR}"
51 + PATH="${_CI_INFRA_OTEL_SHIM_DIR}:${PATH}"
52 + ;;
53 + esac
54 + export CI_INFRA_OTEL_SHIM_PATHS PATH
55 + hash -r 2>/dev/null || :
56 + fi
57 +fi
58 +
59 +_ci_otel_python() {
60 + if command -v timeout >/dev/null 2>&1; then
61 + timeout 2s "${_CI_INFRA_OTEL_PYTHON}" "$@"
62 + else
63 + "${_CI_INFRA_OTEL_PYTHON}" "$@"
64 + fi
65 +}
66 +
67 +ci_otel_start() {
68 + _CI_INFRA_OTEL_COMMAND_INDEX="$1"
69 + _CI_INFRA_OTEL_COMMAND_LABEL="${2:-command ${_CI_INFRA_OTEL_COMMAND_INDEX}}"
70 + _CI_INFRA_OTEL_CONTEXT="$(_ci_otel_python "${CI_INFRA_OTEL_DIR}/ci_otel.py" new-context)" ||
71 + return 0
72 + # The helper prints four whitespace-free fields.
73 + # shellcheck disable=SC2086
74 + set -- ${_CI_INFRA_OTEL_CONTEXT}
75 + [ "$#" -eq 4 ] || return 0
76 +
77 + _CI_INFRA_OTEL_TRACE_ID="$1"
78 + _CI_INFRA_OTEL_COMMAND_SPAN_ID="$2"
79 + _CI_INFRA_OTEL_PARENT_SPAN_ID="$3"
80 + _CI_INFRA_OTEL_START_NS="$4"
81 + CI_INFRA_TRACE_ID="${_CI_INFRA_OTEL_TRACE_ID}"
82 + CI_INFRA_COMMAND_SPAN_ID="${_CI_INFRA_OTEL_COMMAND_SPAN_ID}"
83 + export CI_INFRA_TRACE_ID CI_INFRA_COMMAND_SPAN_ID
84 + _CI_INFRA_OTEL_ACTIVE=1
85 +}
86 +
87 +ci_otel_finish() {
88 + _CI_INFRA_OTEL_COMMAND_STATUS="${1:-0}"
89 + [ "${_CI_INFRA_OTEL_ACTIVE:-0}" = "1" ] || return 0
90 + _CI_INFRA_OTEL_ACTIVE=0
91 + _ci_otel_python "${CI_INFRA_OTEL_DIR}/ci_otel.py" record-command \
92 + "${_CI_INFRA_OTEL_TRACE_ID}" "${_CI_INFRA_OTEL_COMMAND_SPAN_ID}" \
93 + "${_CI_INFRA_OTEL_PARENT_SPAN_ID}" "${_CI_INFRA_OTEL_START_NS}" \
94 + "${_CI_INFRA_OTEL_COMMAND_INDEX}" "${_CI_INFRA_OTEL_COMMAND_STATUS}" \
95 + "${_CI_INFRA_OTEL_COMMAND_LABEL}" || :
96 + CI_INFRA_TRACE_ID=""
97 + CI_INFRA_COMMAND_SPAN_ID=""
98 + export CI_INFRA_TRACE_ID CI_INFRA_COMMAND_SPAN_ID
99 +}
100 +
101 +# Run a simple command with tracing. Only for commands that do not modify
102 +# shell state (export, cd, etc.) — those need the explicit start/finish pair.
103 +# Assignment-prefixed arguments (VAR=value) are routed through env so the
104 +# shell does not try to execute the assignment as a program.
105 +ci_otel_run() {
106 + _CI_INFRA_OTEL_RUN_INDEX="$1"
107 + _CI_INFRA_OTEL_RUN_LABEL="$2"
108 + shift 2
109 + ci_otel_start "${_CI_INFRA_OTEL_RUN_INDEX}" "${_CI_INFRA_OTEL_RUN_LABEL}" || :
110 + env "$@"
111 + _CI_INFRA_OTEL_RUN_STATUS=$?
112 + ci_otel_finish "${_CI_INFRA_OTEL_RUN_STATUS}" || :
113 + return "${_CI_INFRA_OTEL_RUN_STATUS}"
114 +}
115 +
116 +_ci_otel_on_exit() {
117 + _CI_INFRA_OTEL_EXIT_STATUS=$?
118 + trap - 0
119 + ci_otel_finish "${_CI_INFRA_OTEL_EXIT_STATUS}" || :
120 + if command -v timeout >/dev/null 2>&1; then
121 + timeout 4s "${_CI_INFRA_OTEL_PYTHON}" "${CI_INFRA_OTEL_DIR}/ci_otel.py" flush || :
122 + else
123 + "${_CI_INFRA_OTEL_PYTHON}" "${CI_INFRA_OTEL_DIR}/ci_otel.py" flush || :
124 + fi
125 + if [ "${_CI_INFRA_OTEL_OWNS_RUNTIME}" = "1" ]; then
126 + rm -rf -- "${CI_INFRA_OTEL_RUNTIME_DIR}" || :
127 + fi
128 + exit "${_CI_INFRA_OTEL_EXIT_STATUS}"
129 +}
130 +
131 +trap _ci_otel_on_exit 0
132 +_CI_INFRA_OTEL_INITIALIZED=1
133 +:
.buildkite/scripts/ci-otel/ci_pytest.sh new
+42
@@ -0,0 +1,42 @@
1 +#!/bin/sh
2 +
3 +real_pytest=""
4 +old_ifs="${IFS}"
5 +IFS=:
6 +for path_dir in ${PATH}; do
7 + path_dir="${path_dir:-.}"
8 + case ":${CI_INFRA_OTEL_SHIM_PATHS:-}:" in
9 + *":${path_dir}:"*) continue ;;
10 + esac
11 + if [ -x "${path_dir}/pytest" ]; then
12 + real_pytest="${path_dir}/pytest"
13 + break
14 + fi
15 +done
16 +IFS="${old_ifs}"
17 +
18 +if [ -z "${real_pytest}" ]; then
19 + echo "vLLM CI OTel: pytest executable not found" >&2 || :
20 + exit 127
21 +fi
22 +
23 +helper_dir="${CI_INFRA_OTEL_DIR:-}"
24 +otel_pythonpath="${helper_dir}${PYTHONPATH:+:${PYTHONPATH}}"
25 +otel_available() {
26 + if command -v timeout >/dev/null 2>&1; then
27 + timeout 2s env PYTHONPATH="${otel_pythonpath}" python3 -c "import ci_otel" \
28 + >/dev/null 2>&1
29 + else
30 + PYTHONPATH="${otel_pythonpath}" python3 -c "import ci_otel" >/dev/null 2>&1
31 + fi
32 +}
33 +
34 +if [ -n "${helper_dir}" ] &&
35 + [ -n "${CI_INFRA_TRACE_ID:-}" ] &&
36 + [ -n "${CI_INFRA_COMMAND_SPAN_ID:-}" ] &&
37 + otel_available; then
38 + PYTHONPATH="${otel_pythonpath}" exec "${real_pytest}" -p ci_otel "$@"
39 +fi
40 +
41 +echo "vLLM CI OTel: pytest tracing skipped; running pytest normally" >&2 || :
42 +exec "${real_pytest}" "$@"
.buildkite/scripts/ci-otel/tests/test_ci_otel.py new
+788
@@ -0,0 +1,788 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from __future__ import annotations
5 +
6 +import json
7 +import os
8 +import shlex
9 +import subprocess
10 +import sys
11 +import time
12 +from pathlib import Path
13 +
14 +import pytest
15 +
16 +SCRIPTS_DIR = Path(__file__).resolve().parents[1]
17 +sys.path.insert(0, str(SCRIPTS_DIR))
18 +
19 +import ci_otel # noqa: E402
20 +from ci_otel import Span # noqa: E402
21 +
22 +
23 +def _span(**attributes) -> Span:
24 + return Span(
25 + trace_id="01" * 16,
26 + span_id="02" * 8,
27 + parent_span_id=None,
28 + name="ci.command",
29 + start_ns=100,
30 + end_ns=200,
31 + attributes=attributes,
32 + )
33 +
34 +
35 +def _quoted(value: str) -> str:
36 + return shlex.quote(value)
37 +
38 +
39 +def test_context_continues_traceparent(monkeypatch):
40 + monkeypatch.setenv(
41 + "TRACEPARENT",
42 + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
43 + )
44 +
45 + trace_id, span_id, parent_span_id = ci_otel.new_context()
46 +
47 + assert trace_id == "4bf92f3577b34da6a3ce929d0e0e4736"
48 + assert len(span_id) == 16
49 + assert parent_span_id == "00f067aa0ba902b7"
50 +
51 +
52 +def test_otlp_payload_contains_dashboard_identity(monkeypatch):
53 + monkeypatch.setenv("BUILDKITE_ORGANIZATION_SLUG", "vllm")
54 + monkeypatch.setenv("BUILDKITE_PIPELINE_SLUG", "ci")
55 + monkeypatch.setenv("BUILDKITE_BUILD_ID", "build-id")
56 + monkeypatch.setenv("BUILDKITE_BUILD_NUMBER", "42")
57 + monkeypatch.setenv("BUILDKITE_JOB_ID", "job-id")
58 +
59 + payload = ci_otel.encode_request([_span(**{"ci.span.kind": "command"})])
60 +
61 + for value in (b"ci.command", b"ci.span.kind", b"buildkite.job.id", b"job-id"):
62 + assert value in payload
63 +
64 +
65 +def test_otlp_payload_accepts_negative_integer_attributes():
66 + payload = ci_otel.encode_request([_span(**{"process.exit.code": -1})])
67 +
68 + assert b"process.exit.code" in payload
69 +
70 +
71 +def test_spool_round_trip_is_local(monkeypatch, tmp_path):
72 + monkeypatch.setenv("CI_INFRA_OTEL_SPOOL_DIR", str(tmp_path))
73 + monkeypatch.setattr(
74 + ci_otel,
75 + "_oidc_token",
76 + lambda deadline: (_ for _ in ()).throw(AssertionError("unexpected upload")),
77 + )
78 + span = _span(**{"ci.command.index": 1})
79 +
80 + assert ci_otel.record_spans([span]) is True
81 + assert ci_otel.load_spans() == [span]
82 +
83 +
84 +def test_buildx_ref_supports_bake_metadata(tmp_path):
85 + metadata = tmp_path / "metadata.json"
86 + metadata.write_text(
87 + json.dumps({"test-ci": {"buildx.build.ref": "builder/node/record-id"}})
88 + )
89 +
90 + assert ci_otel.buildx_ref(str(metadata), "test-ci") == "builder/node/record-id"
91 +
92 +
93 +def test_buildkit_trace_groups_stages_and_includes_descendant_time(monkeypatch):
94 + monkeypatch.setenv("BUILDKITE_BUILD_ID", "build-id")
95 + payload = {
96 + "data": [
97 + {
98 + "spans": [
99 + {
100 + "spanID": "01" * 8,
101 + "operationName": "[base 1/2] FROM alpine:3.21",
102 + "startTime": 100,
103 + "duration": 10,
104 + "tags": [{"key": "vertex", "value": "sha256:from"}],
105 + },
106 + {
107 + "spanID": "02" * 8,
108 + "operationName": "registry pull",
109 + "references": [{"refType": "CHILD_OF", "spanID": "01" * 8}],
110 + "startTime": 105,
111 + "duration": 95,
112 + },
113 + {
114 + "spanID": "03" * 8,
115 + "operationName": "[base 2/2] RUN uv pip install vllm",
116 + "startTime": 210,
117 + "duration": 40,
118 + "tags": [{"key": "vertex", "value": "sha256:run"}],
119 + },
120 + {
121 + "spanID": "04" * 8,
122 + "operationName": "[test 1/1] COPY --from=base /opt /opt",
123 + "startTime": 260,
124 + "duration": 20,
125 + "tags": [{"key": "vertex", "value": "sha256:copy"}],
126 + },
127 + {
128 + "spanID": "05" * 8,
129 + "operationName": "[internal] load build context",
130 + "startTime": 90,
131 + "duration": 30,
132 + "tags": [{"key": "vertex", "value": "sha256:context"}],
133 + },
134 + {
135 + "spanID": "06" * 8,
136 + "operationName": (
137 + "cache request: [base 2/2] RUN uv pip install vllm"
138 + ),
139 + "startTime": 205,
140 + "duration": 2,
141 + "tags": [{"key": "vertex", "value": "sha256:run"}],
142 + },
143 + ]
144 + }
145 + ]
146 + }
147 +
148 + spans = ci_otel.buildkit_spans(payload)
149 + stages = {
150 + span.attributes.get("docker.stage"): span
151 + for span in spans
152 + if span.name == "docker.stage"
153 + }
154 + instructions = [span for span in spans if span.name == "docker.instruction"]
155 +
156 + assert set(stages) == {"base", "test"}
157 + assert stages["base"].start_ns == 100_000
158 + assert stages["base"].end_ns == 250_000
159 + assert len(instructions) == 3
160 + assert len([span for span in spans if span.name == "docker.internal"]) == 1
161 + from_span = next(
162 + span for span in instructions if span.attributes["docker.instruction"] == "FROM"
163 + )
164 + assert from_span.end_ns == 200_000
165 + assert from_span.parent_span_id == stages["base"].span_id
166 + assert all(span.trace_id == stages["base"].trace_id for span in spans)
167 +
168 +
169 +def test_record_buildkit_trace_spools_normalized_spans(monkeypatch, tmp_path):
170 + trace_file = tmp_path / "trace.json"
171 + trace_file.write_text(
172 + json.dumps(
173 + {
174 + "data": [
175 + {
176 + "spans": [
177 + {
178 + "spanID": "01" * 8,
179 + "operationName": "[base 1/1] RUN true",
180 + "startTime": 100,
181 + "duration": 20,
182 + }
183 + ]
184 + }
185 + ]
186 + }
187 + )
188 + )
189 + monkeypatch.setenv("CI_INFRA_OTEL_SPOOL_DIR", str(tmp_path / "spool"))
190 +
191 + assert ci_otel.record_buildkit_trace(str(trace_file)) is True
192 + assert {span.attributes["ci.span.kind"] for span in ci_otel.load_spans()} == {
193 + "docker-stage",
194 + "docker-instruction",
195 + }
196 +
197 +
198 +def test_export_uses_buildkite_oidc_and_otlp(monkeypatch):
199 + for name, value in {
200 + "BUILDKITE": "true",
201 + "BUILDKITE_AGENT_ACCESS_TOKEN": "agent-token",
202 + "BUILDKITE_AGENT_ENDPOINT": "https://agent.example/v3",
203 + "BUILDKITE_JOB_ID": "job-id",
204 + }.items():
205 + monkeypatch.setenv(name, value)
206 + requests = []
207 +
208 + class Response:
209 + status = 200
210 +
211 + def __init__(self, body=b""):
212 + self.body = body
213 +
214 + def read(self):
215 + return self.body
216 +
217 + def __enter__(self):
218 + return self
219 +
220 + def __exit__(self, *args):
221 + return False
222 +
223 + def open_request(request, timeout):
224 + requests.append((request, timeout))
225 + if request.full_url.endswith("/oidc/tokens"):
226 + return Response(b'{"token":"oidc-token"}')
227 + return Response()
228 +
229 + monkeypatch.setattr(ci_otel.urllib.request, "urlopen", open_request)
230 +
231 + assert ci_otel.export_spans([_span()], timeout_seconds=0.5) is True
232 + oidc_request, upload_request = (request for request, _ in requests)
233 + assert oidc_request.full_url.endswith("/jobs/job-id/oidc/tokens")
234 + assert oidc_request.get_header("Authorization") == "Token agent-token"
235 + assert json.loads(oidc_request.data)["audience"] == ci_otel.AUDIENCE
236 + assert upload_request.get_header("Authorization") == "Bearer oidc-token"
237 + assert upload_request.get_header("Content-type") == "application/x-protobuf"
238 + assert all(0 < timeout <= 0.5 for _, timeout in requests)
239 +
240 +
241 +def test_export_failure_is_soft_and_bounded(monkeypatch):
242 + monkeypatch.setenv("BUILDKITE", "true")
243 + monkeypatch.setattr(
244 + ci_otel,
245 + "_oidc_token",
246 + lambda deadline: (_ for _ in ()).throw(TimeoutError("unavailable")),
247 + )
248 +
249 + started = time.monotonic()
250 + assert ci_otel.export_spans([_span()], timeout_seconds=0.1) is False
251 + assert time.monotonic() - started < 0.5
252 +
253 +
254 +def test_export_batches_with_one_oidc_token(monkeypatch):
255 + monkeypatch.setenv("BUILDKITE", "true")
256 + monkeypatch.setattr(ci_otel, "MAX_BATCH_SIZE", 2)
257 + token_calls = []
258 + requests = []
259 +
260 + class Response:
261 + status = 200
262 +
263 + def __enter__(self):
264 + return self
265 +
266 + def __exit__(self, *args):
267 + return False
268 +
269 + def token(deadline):
270 + token_calls.append(deadline)
271 + return "token"
272 +
273 + def open_request(request, timeout):
274 + requests.append(request)
275 + return Response()
276 +
277 + monkeypatch.setattr(ci_otel, "_oidc_token", token)
278 + monkeypatch.setattr(ci_otel.urllib.request, "urlopen", open_request)
279 +
280 + assert ci_otel.export_spans([_span() for _ in range(5)]) is True
281 + assert len(token_calls) == 1
282 + assert len(requests) == 3
283 +
284 +
285 +def test_successful_flush_removes_spool_and_does_not_duplicate(monkeypatch, tmp_path):
286 + monkeypatch.setenv("CI_INFRA_OTEL_SPOOL_DIR", str(tmp_path))
287 + uploaded = []
288 + assert ci_otel.record_spans([_span()]) is True
289 + monkeypatch.setattr(
290 + ci_otel, "export_spans", lambda spans, timeout: uploaded.extend(spans) or True
291 + )
292 +
293 + assert ci_otel.flush_spans() is True
294 + assert len(uploaded) == 1
295 + assert list(tmp_path.glob("spans-*.jsonl")) == []
296 + assert ci_otel.flush_spans() is True
297 + assert len(uploaded) == 1
298 +
299 +
300 +def test_failed_flush_preserves_spool(monkeypatch, tmp_path):
301 + monkeypatch.setenv("CI_INFRA_OTEL_SPOOL_DIR", str(tmp_path))
302 + assert ci_otel.record_spans([_span()]) is True
303 + monkeypatch.setattr(ci_otel, "export_spans", lambda spans, timeout: False)
304 +
305 + assert ci_otel.flush_spans() is False
306 + assert len(list(tmp_path.glob("spans-*.jsonl"))) == 1
307 +
308 +
309 +def test_cli_rejects_bad_arguments_without_traceback(capsys):
310 + assert ci_otel.main(["bogus"]) == 2
311 + assert ci_otel.main(["record-command", "too", "short"]) == 2
312 + assert (
313 + ci_otel.main(["record-command", "t", "s", "-", "not-an-int", "1", "0", "label"])
314 + == 1
315 + )
316 +
317 + stderr = capsys.readouterr().err
318 + assert "usage:" in stderr
319 + assert "CI timing helper failed:" in stderr
320 + assert "Traceback" not in stderr
321 +
322 +
323 +def test_pytest_spans_are_spooled_incrementally(monkeypatch):
324 + recorded = []
325 + monkeypatch.setenv("CI_INFRA_TRACE_ID", "01" * 16)
326 + monkeypatch.setenv("CI_INFRA_COMMAND_SPAN_ID", "02" * 8)
327 + monkeypatch.setattr(ci_otel, "TEST_SPOOL_BATCH_SIZE", 1)
328 + monkeypatch.setattr(
329 + ci_otel, "record_spans", lambda spans: recorded.extend(spans) or True
330 + )
331 + ci_otel._test_runs.clear()
332 + ci_otel._test_spans.clear()
333 + ci_otel._test_runs["test_sample.py::test_one"] = (time.time_ns(), "passed")
334 +
335 + ci_otel.pytest_runtest_logfinish("test_sample.py::test_one", ("", 0, ""))
336 +
337 + assert len(recorded) == 1
338 + assert ci_otel._test_spans == []
339 +
340 +
341 +def test_shell_wrapper_records_commands_without_changing_shell_state(tmp_path):
342 + first = f"ci_otel_start 1 {_quoted('export VALUE=ready')}"
343 + second = f"ci_otel_start 2 {_quoted('check VALUE')}"
344 + shell = (
345 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; {first}; '
346 + f"export VALUE=ready; ci_otel_finish 0; {second}; "
347 + 'test "$VALUE" = ready; ci_otel_finish 0'
348 + )
349 +
350 + result = subprocess.run(
351 + ["/bin/sh", "-c", shell],
352 + check=False,
353 + capture_output=True,
354 + text=True,
355 + env={
356 + **os.environ,
357 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
358 + "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path),
359 + },
360 + )
361 +
362 + assert result.returncode == 0, result.stderr
363 + records = "".join(path.read_text() for path in tmp_path.glob("spans-*.jsonl"))
364 + assert "export VALUE=ready" in records
365 + assert "check VALUE" in records
366 +
367 +
368 +def test_shell_wrapper_preserves_failure_status(tmp_path):
369 + shell = (
370 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
371 + f"ci_otel_start 1 {_quoted('false')}; false; status=$?; "
372 + 'ci_otel_finish "$status"; exit "$status"'
373 + )
374 +
375 + result = subprocess.run(
376 + ["/bin/sh", "-c", shell],
377 + check=False,
378 + capture_output=True,
379 + text=True,
380 + env={
381 + **os.environ,
382 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
383 + "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path),
384 + },
385 + )
386 +
387 + assert result.returncode == 1
388 +
389 +
390 +def test_ci_otel_run_records_command_and_preserves_status(tmp_path):
391 + shell = (
392 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
393 + f"ci_otel_run 1 {_quoted('true')} true; "
394 + f"ci_otel_run 2 {_quoted('false')} false; "
395 + 'echo "status=$?"'
396 + )
397 +
398 + result = subprocess.run(
399 + ["/bin/sh", "-c", shell],
400 + check=False,
401 + capture_output=True,
402 + text=True,
403 + env={
404 + **os.environ,
405 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
406 + "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path),
407 + },
408 + )
409 +
410 + assert result.returncode == 0, result.stderr
411 + assert "status=1" in result.stdout
412 + records = "".join(path.read_text() for path in tmp_path.glob("spans-*.jsonl"))
413 + assert '"ci.command.index":1' in records
414 + assert '"ci.command.index":2' in records
415 + assert '"process.exit.code":0' in records
416 + assert '"process.exit.code":1' in records
417 +
418 +
419 +def test_ci_otel_run_fail_open_when_tracing_unavailable(tmp_path):
420 + shell = (
421 + f'CI_INFRA_OTEL_DIR="{tmp_path / "missing"}"; export CI_INFRA_OTEL_DIR; '
422 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
423 + f"ci_otel_run 1 {_quoted('echo ran')} echo ran"
424 + )
425 +
426 + result = subprocess.run(
427 + ["/bin/sh", "-e", "-c", shell],
428 + check=False,
429 + capture_output=True,
430 + text=True,
431 + )
432 +
433 + assert result.returncode == 0, result.stderr
434 + assert result.stdout.strip() == "ran"
435 +
436 +
437 +def test_ci_otel_run_handles_assignment_prefixed_commands(tmp_path):
438 + shell = (
439 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
440 + f"ci_otel_run 1 {_quoted('MY_VAR=hello env')} "
441 + "MY_VAR=hello env"
442 + )
443 +
444 + result = subprocess.run(
445 + ["/bin/sh", "-c", shell],
446 + check=False,
447 + capture_output=True,
448 + text=True,
449 + env={
450 + **os.environ,
451 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
452 + "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path),
453 + },
454 + )
455 +
456 + assert result.returncode == 0, result.stderr
457 + assert "MY_VAR=hello" in result.stdout
458 +
459 +
460 +def test_missing_helpers_do_not_block_the_test_command(tmp_path):
461 + output = tmp_path / "ran"
462 + shell = (
463 + f'CI_INFRA_OTEL_DIR="{tmp_path / "missing"}"; export CI_INFRA_OTEL_DIR; '
464 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; printf ran > "{output}"'
465 + )
466 +
467 + result = subprocess.run(
468 + ["/bin/sh", "-e", "-c", shell],
469 + check=False,
470 + capture_output=True,
471 + text=True,
472 + )
473 +
474 + assert result.returncode == 0, result.stderr
475 + assert output.read_text() == "ran"
476 +
477 +
478 +def test_unset_helper_dir_does_not_block_the_test_command():
479 + shell = f'unset CI_INFRA_OTEL_DIR; . "{SCRIPTS_DIR / "ci_otel.sh"}"; echo ran'
480 +
481 + result = subprocess.run(
482 + ["/bin/bash", "-e", "-c", shell],
483 + check=False,
484 + capture_output=True,
485 + text=True,
486 + )
487 +
488 + assert result.returncode == 0, result.stderr
489 + assert result.stdout.strip() == "ran"
490 +
491 +
492 +def test_helper_dir_assignment_is_exported():
493 + shell = (
494 + "unset CI_INFRA_OTEL_DIR; "
495 + f'CI_INFRA_OTEL_DIR="{SCRIPTS_DIR}" . "{SCRIPTS_DIR / "ci_otel.sh"}"; '
496 + f'/bin/sh -c \'test "$CI_INFRA_OTEL_DIR" = "{SCRIPTS_DIR}"\''
497 + )
498 +
499 + result = subprocess.run(
500 + ["/bin/sh", "-c", shell], check=False, capture_output=True, text=True
501 + )
502 +
503 + assert result.returncode == 0, result.stderr
504 +
505 +
506 +@pytest.mark.parametrize("failure", ["new-context", "record-command"])
507 +def test_helper_failure_cannot_change_job_status(tmp_path, failure):
508 + fake_bin = tmp_path / "bin"
509 + fake_bin.mkdir()
510 + fake_python = fake_bin / "python3"
511 + fake_python.write_text(
512 + "#!/bin/sh\n"
513 + '[ "$1" = "-c" ] && exit 0\n'
514 + '[ "$2" = "new-context" ] && {\n'
515 + ' [ "$CI_OTEL_TEST_FAILURE" = "new-context" ] && exit 99\n'
516 + ' echo "01010101010101010101010101010101 0202020202020202 - 1"\n'
517 + " exit 0\n"
518 + "}\n"
519 + '[ "$2" = "record-command" ] && exit 99\n'
520 + "exit 99\n"
521 + )
522 + fake_python.chmod(0o755)
523 + shell = (
524 + f'PATH="{fake_bin}:$PATH"; . "{SCRIPTS_DIR / "ci_otel.sh"}"; '
525 + f"ci_otel_start 1 {_quoted('true')}; ci_otel_finish 0; echo ran"
526 + )
527 +
528 + result = subprocess.run(
529 + ["/bin/sh", "-e", "-c", shell],
530 + check=False,
531 + capture_output=True,
532 + text=True,
533 + env={
534 + **os.environ,
535 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
536 + "CI_INFRA_OTEL_RUNTIME_DIR": str(tmp_path / "runtime"),
537 + "CI_OTEL_TEST_FAILURE": failure,
538 + },
539 + )
540 +
541 + assert result.returncode == 0, result.stderr
542 + assert result.stdout.strip() == "ran"
543 +
544 +
545 +def test_flush_failure_preserves_job_status(tmp_path):
546 + fake_bin = tmp_path / "bin"
547 + fake_bin.mkdir()
548 + fake_python = fake_bin / "python3"
549 + fake_python.write_text("#!/bin/sh\nexit 99\n")
550 + fake_python.chmod(0o755)
551 +
552 + def run(status: int):
553 + shell = (
554 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
555 + f'PATH="{fake_bin}"; export PATH; exit {status}'
556 + )
557 + return subprocess.run(
558 + ["/bin/sh", "-c", shell],
559 + check=False,
560 + capture_output=True,
561 + text=True,
562 + env={**os.environ, "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR)},
563 + )
564 +
565 + assert run(0).returncode == 0
566 + assert run(7).returncode == 7
567 +
568 +
569 +def test_shell_wrapper_uses_private_trace_ids(tmp_path):
570 + shell = (
571 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
572 + f"ci_otel_start 1 {_quoted('true')}; "
573 + 'expected="$CI_INFRA_TRACE_ID"; '
574 + "CI_INFRA_TRACE_ID=corrupted; CI_INFRA_COMMAND_SPAN_ID=corrupted; "
575 + 'ci_otel_finish 0; printf "%s" "$expected"'
576 + )
577 +
578 + result = subprocess.run(
579 + ["/bin/sh", "-c", shell],
580 + check=False,
581 + capture_output=True,
582 + text=True,
583 + env={
584 + **os.environ,
585 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
586 + "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path),
587 + },
588 + )
589 +
590 + assert result.returncode == 0, result.stderr
591 + record = json.loads(next(tmp_path.glob("spans-*.jsonl")).read_text())
592 + assert record["trace_id"] == result.stdout
593 + assert record["span_id"] != "corrupted"
594 +
595 +
596 +def test_shell_setup_is_idempotent(tmp_path):
597 + runtime = tmp_path / "runtime"
598 + shell = (
599 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
600 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
601 + 'printf "%s\n%s" "$PATH" "$CI_INFRA_OTEL_SHIM_PATHS"'
602 + )
603 +
604 + result = subprocess.run(
605 + ["/bin/sh", "-c", shell],
606 + check=False,
607 + capture_output=True,
608 + text=True,
609 + env={
610 + **os.environ,
611 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
612 + "CI_INFRA_OTEL_RUNTIME_DIR": str(runtime),
613 + },
614 + )
615 +
616 + assert result.returncode == 0, result.stderr
617 + path, shim_paths = result.stdout.splitlines()
618 + assert path.split(":").count(str(runtime / "bin")) == 1
619 + assert shim_paths.split(":").count(str(runtime / "bin")) == 1
620 +
621 +
622 +def test_helper_owned_runtime_is_removed_on_exit(tmp_path):
623 + runtime_file = tmp_path / "runtime"
624 + shell = (
625 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"; '
626 + f'printf "%s" "$CI_INFRA_OTEL_RUNTIME_DIR" > "{runtime_file}"'
627 + )
628 +
629 + result = subprocess.run(
630 + ["/bin/sh", "-c", shell],
631 + check=False,
632 + capture_output=True,
633 + text=True,
634 + env={**os.environ, "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR)},
635 + )
636 +
637 + assert result.returncode == 0, result.stderr
638 + assert not Path(runtime_file.read_text()).exists()
639 +
640 +
641 +def test_caller_owned_runtime_is_preserved(tmp_path):
642 + runtime = tmp_path / "runtime"
643 + shell = f'. "{SCRIPTS_DIR / "ci_otel.sh"}"'
644 +
645 + result = subprocess.run(
646 + ["/bin/sh", "-c", shell],
647 + check=False,
648 + capture_output=True,
649 + text=True,
650 + env={
651 + **os.environ,
652 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
653 + "CI_INFRA_OTEL_RUNTIME_DIR": str(runtime),
654 + },
655 + )
656 +
657 + assert result.returncode == 0, result.stderr
658 + assert runtime.exists()
659 +
660 +
661 +def test_pytest_shim_recovers_from_stale_recorded_path(tmp_path):
662 + bin_dir = tmp_path / "bin"
663 + bin_dir.mkdir()
664 + real_pytest = bin_dir / "pytest"
665 + real_pytest.write_text("#!/bin/sh\nprintf 'CURRENT-PYTEST'")
666 + real_pytest.chmod(0o755)
667 +
668 + result = subprocess.run(
669 + [str(SCRIPTS_DIR / "ci_pytest.sh"), "--version"],
670 + check=False,
671 + capture_output=True,
672 + text=True,
673 + env={
674 + **os.environ,
675 + "PATH": str(bin_dir),
676 + "CI_INFRA_OTEL_REAL_PYTEST": "/missing/pytest",
677 + },
678 + )
679 +
680 + assert result.returncode == 0, result.stderr
681 + assert result.stdout == "CURRENT-PYTEST"
682 +
683 +
684 +def test_path_change_after_source_uses_new_pytest(tmp_path):
685 + old_bin = tmp_path / "old"
686 + new_bin = tmp_path / "new"
687 + runtime = tmp_path / "runtime"
688 + old_bin.mkdir()
689 + new_bin.mkdir()
690 + for directory, output in ((old_bin, "OLD"), (new_bin, "NEW")):
691 + executable = directory / "pytest"
692 + executable.write_text(f"#!/bin/sh\nprintf '{output}'")
693 + executable.chmod(0o755)
694 + shell = (
695 + f'PATH="{old_bin}:$PATH"; . "{SCRIPTS_DIR / "ci_otel.sh"}"; '
696 + f'PATH="{new_bin}:$PATH"; hash -r; pytest'
697 + )
698 +
699 + result = subprocess.run(
700 + ["/bin/sh", "-c", shell],
701 + check=False,
702 + capture_output=True,
703 + text=True,
704 + env={
705 + **os.environ,
706 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
707 + "CI_INFRA_OTEL_RUNTIME_DIR": str(runtime),
708 + },
709 + )
710 +
711 + assert result.returncode == 0, result.stderr
712 + assert result.stdout == "NEW"
713 +
714 +
715 +def test_pytest_shim_records_distinct_test_intervals_with_pythonpath_override(
716 + tmp_path,
717 +):
718 + test_file = tmp_path / "test_sample.py"
719 + test_file.write_text(
720 + "import time\n"
721 + "def test_first(): time.sleep(0.02)\n"
722 + "def test_second(): time.sleep(0.02)\n"
723 + )
724 + workspace = tmp_path / "workspace"
725 + workspace.mkdir()
726 + bin_dir = tmp_path / "bin"
727 + bin_dir.mkdir()
728 + pytest_executable = bin_dir / "pytest"
729 + pytest_executable.write_text(
730 + f'#!/bin/sh\nexec {shlex.quote(sys.executable)} -m pytest "$@"\n'
731 + )
732 + pytest_executable.chmod(0o755)
733 + shell = (
734 + f'. "{SCRIPTS_DIR / "ci_otel.sh"}"\n'
735 + f"ci_otel_start 1 {_quoted('pytest tests')}\n"
736 + f"PYTHONPATH={shlex.quote(str(workspace))} "
737 + f"pytest -q {shlex.quote(str(test_file))}\n"
738 + "status=$?\n"
739 + 'ci_otel_finish "$status"\n'
740 + 'exit "$status"'
741 + )
742 +
743 + result = subprocess.run(
744 + ["/bin/sh", "-c", shell],
745 + check=False,
746 + capture_output=True,
747 + text=True,
748 + env={
749 + **os.environ,
750 + "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
751 + "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path / "spans"),
752 + "PATH": f"{bin_dir}:{os.environ['PATH']}",
753 + "PYTEST_ADDOPTS": "",
754 + },
755 + )
756 +
757 + assert result.returncode == 0, result.stderr
758 + records = [
759 + json.loads(line)
760 + for path in (tmp_path / "spans").glob("spans-*.jsonl")
761 + for line in path.read_text().splitlines()
762 + ]
763 + tests = [record for record in records if record["name"] == "pytest.test"]
764 + assert len(tests) == 2
765 + assert tests[0]["start_ns"] < tests[1]["start_ns"]
766 + assert tests[0]["end_ns"] <= tests[1]["start_ns"]
767 +
768 +
769 +def test_pytest_plugin_failure_cannot_fail_pytest(tmp_path):
770 + test_file = tmp_path / "test_sample.py"
771 + test_file.write_text("def test_sample(): assert True\n")
772 +
773 + result = subprocess.run(
774 + [sys.executable, "-m", "pytest", "-q", "-p", "ci_otel", str(test_file)],
775 + check=False,
776 + capture_output=True,
777 + text=True,
778 + env={
779 + **os.environ,
780 + "PYTHONPATH": str(SCRIPTS_DIR),
781 + "CI_INFRA_TRACE_ID": "01" * 16,
782 + "CI_INFRA_COMMAND_SPAN_ID": "02" * 8,
783 + "CI_INFRA_OTEL_SPOOL_DIR": "/dev/null/spans",
784 + },
785 + )
786 +
787 + assert result.returncode == 0, result.stderr
788 + assert "1 passed" in result.stdout