GitHub Actions 在组织里铺开后,账单通常比可观测性增长得更快。团队能看到某次构建失败,却很难立即回答:时间消耗在排队还是执行?哪个 job 长期拖慢流水线?失败后重跑成功的概率有多高?
解决这类问题不一定要给每个仓库修改 workflow YAML。更低侵入的做法,是从 GitHub 的控制面采集 workflow_run Webhook,再通过 Actions API 补齐 job 和 step 数据,最后把一次流水线转换成一条分布式追踪。
把一次流水线映射成一条 Trace
可以采用下面的模型:
| CI 对象 | 追踪对象 | 关键属性 |
|---|---|---|
| Workflow run | Trace 根 Span | repository、workflow、run ID、commit、attempt |
| Job | 子 Span | job name、runner labels、conclusion |
| Queue time | Job 下的子 Span | created_at 到 started_at |
| Step | Job 下的子 Span | step name、number、conclusion |
这种映射保留了时间轴和父子关系。打开一条 trace 时,可以直接看到多个 job 是串行还是并行,以及真正拖慢流水线的是排队、环境准备,还是某个具体步骤。
采集服务位于 workflow 之外:
GitHub Webhook
│ workflow_run.completed
▼
CI Trace Receiver ──调用 Actions Jobs API──▶ GitHub API
│
│ OTLP traces
▼
OpenTelemetry Collector / Trace Backend
因此,不需要向数百个仓库复制 action,也不会因为业务团队重构 YAML 而丢失埋点。生产环境通常会把接收器实现成组织级 GitHub App;原型阶段也可以使用只读、细粒度访问令牌。
一个可运行的 Webhook 到 OTLP 示例
下面是一个最小实现。它监听已完成的 workflow_run 事件,获取该 run 的全部 jobs,然后生成 workflow、job、排队和 step spans。
运行前需要准备:
- 一个 GitHub Webhook secret;
- 可读取目标仓库 Actions 数据的令牌,生产环境建议换成 GitHub App installation token;
- 一个支持 OTLP/HTTP 的 Collector 或追踪后端;
- 在 GitHub Webhook 中订阅
workflow_run事件,并把接收地址指向公开的 HTTPS/github/webhook。
安装依赖:
python -m venv .venv
. .venv/bin/activate
pip install flask requests opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
保存为 app.py:
import hashlib
import hmac
import os
from datetime import datetime
import requests
from flask import Flask, abort, request
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.trace import Status, StatusCode
app = Flask(__name__)
provider = TracerProvider(
resource=Resource.create({"service.name": "github-actions-trace-receiver"})
)
provider.add_span_processor(
SimpleSpanProcessor(
OTLPSpanExporter(
endpoint=os.environ.get(
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"http://localhost:4318/v1/traces",
)
)
)
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("ci.github-actions")
def timestamp_ns(value):
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1e9)
def verify_signature(body, signature):
secret = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
def apply_status(span, conclusion):
if conclusion == "success":
span.set_status(Status(StatusCode.OK))
elif conclusion in {"failure", "timed_out", "startup_failure"}:
span.set_status(Status(StatusCode.ERROR, conclusion))
def list_jobs(repository, run_id):
token = os.environ["GITHUB_TOKEN"]
url = f"https://api.github.com/repos/{repository}/actions/runs/{run_id}/jobs"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
jobs = []
params = {"per_page": 100}
while url:
response = requests.get(url, headers=headers, params=params, timeout=20)
response.raise_for_status()
jobs.extend(response.json()["jobs"])
url = response.links.get("next", {}).get("url")
params = None
return jobs
@app.post("/github/webhook")
def github_webhook():
body = request.get_data()
if not verify_signature(body, request.headers.get("X-Hub-Signature-256")):
abort(401)
if request.headers.get("X-GitHub-Event") != "workflow_run":
return {"ignored": True}
payload = request.get_json()
if payload.get("action") != "completed":
return {"ignored": True}
run = payload["workflow_run"]
repository = payload["repository"]["full_name"]
jobs = list_jobs(repository, run["id"])
run_span = tracer.start_span(
"github.workflow_run",
start_time=timestamp_ns(run["created_at"]),
attributes={
"github.repository": repository,
"github.workflow.name": run["name"],
"github.run.id": run["id"],
"github.run.attempt": run.get("run_attempt", 1),
"github.head.sha": run["head_sha"],
"github.event": run["event"],
"github.conclusion": run["conclusion"],
},
)
run_context = trace.set_span_in_context(run_span)
for job in jobs:
job_span = tracer.start_span(
f"github.job.{job['name']}",
context=run_context,
start_time=timestamp_ns(job["created_at"]),
attributes={
"github.job.id": job["id"],
"github.job.name": job["name"],
"github.job.conclusion": job.get("conclusion") or "unknown",
"github.runner.labels": ",".join(job.get("labels", [])),
},
)
job_context = trace.set_span_in_context(job_span)
if job.get("started_at"):
queue_span = tracer.start_span(
"github.job.queue",
context=job_context,
start_time=timestamp_ns(job["created_at"]),
)
queue_span.end(end_time=timestamp_ns(job["started_at"]))
for step in job.get("steps", []):
if not step.get("started_at") or not step.get("completed_at"):
continue
step_span = tracer.start_span(
f"github.step.{step['name']}",
context=job_context,
start_time=timestamp_ns(step["started_at"]),
attributes={
"github.step.number": step["number"],
"github.step.conclusion": step.get("conclusion") or "unknown",
},
)
apply_status(step_span, step.get("conclusion"))
step_span.end(end_time=timestamp_ns(step["completed_at"]))
apply_status(job_span, job.get("conclusion"))
job_span.end(end_time=timestamp_ns(job["completed_at"]))
apply_status(run_span, run.get("conclusion"))
run_span.end(end_time=timestamp_ns(run["updated_at"]))
return {"accepted": True, "jobs": len(jobs)}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
配置并启动:
export GITHUB_WEBHOOK_SECRET='replace-with-your-webhook-secret'
export GITHUB_TOKEN='replace-with-a-read-only-token'
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://localhost:4318/v1/traces'
python app.py
示例使用 SimpleSpanProcessor,便于在 Webhook 请求结束前完成导出。吞吐量较大时,应改用 BatchSpanProcessor,并将接收器部署成持续运行的服务。
从 Trace 回答运营问题
有了 spans,还需要把问题转换成稳定的查询口径。
流水线为什么慢:比较 workflow 总时长、各 job 时长和 github.job.queue 时长。如果执行时间稳定而 queue span 持续变长,瓶颈更可能在 runner 容量,而不是测试代码。
哪些 job 不稳定:按 github.repository、github.job.name 和 github.head.sha 聚合结果。相同提交在较高 github.run.attempt 中从失败转为成功,可以作为 flaky 信号,但不能直接等同于测试本身不稳定——网络、缓存、runner 故障也会造成同样现象。
并发是否合理:trace 时间轴会展示 jobs 的重叠程度。理论上可并行却长期串行的任务,可能受 needs 依赖、并发组、环境审批或 runner 标签约束。
排队时间是否掩盖了优化收益:单看 job 的开始到结束会漏掉排队成本。将 created_at → started_at 单独建 span,才能区分“代码执行慢”和“资源没排上”。
上线前需要补齐的边界
这个方案避免修改 workflow,但并不意味着没有工程成本。正式采用前建议检查:
- 去重:GitHub 会重投 Webhook。按
X-GitHub-Delivery保存幂等记录,否则会产生重复 traces。 - 权限最小化:令牌只开放 Actions 元数据读取权限,不要把长期 PAT 硬编码进镜像。
- 组织级覆盖:优先使用 GitHub App 管理仓库安装范围和短期 installation token。
- 数据基数:run ID、job ID 和 commit SHA 适合作为 trace 属性,但不一定适合作为指标标签,否则可能造成高基数成本。
- 采样策略:如果只保留失败 traces,就无法计算可信的成功率和延迟分位数。可以全量保留指标,同时对成功 traces 采样。
- 时间限制:这里生成的是“延迟到达”的历史 spans。需要确认后端接受显式历史时间戳,并允许对应的时间偏差。
- 可见性边界:外部采集可以看到 workflow、job 和 step,却看不到测试进程内部的数据库调用或 HTTP 请求。要把 CI trace 与应用级 trace 连起来,仍需在测试命令或应用中加入 OpenTelemetry 上下文传播。
适合的落地顺序是:先覆盖少量高消耗仓库,验证排队时间、失败率和 P95 job 时长是否可信;再增加去重、GitHub App 鉴权和批量导出;最后才建立组织级告警与成本报表。这样既能保持 workflow 文件不变,也能避免一开始就建设过重的 CI 可观测平台。