Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,36 @@ GitHub Actions summary for easy access:

![Trace ID Summary](./img/trace-id-summary.png)

## Deterministic Trace IDs

By default, trace and span IDs are random. Set `DETERMINISTIC_TRACE_IDS: true`
to derive them from the workflow run instead, so another tool (for example, a
step that exports test results) can add spans to the same trace without talking
to this action:

```yaml
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DETERMINISTIC_TRACE_IDS: true
```

Each ID is the leading bytes of the hex SHA-256 of a key:

| Span | Key | Length |
| ----------------------------------- | --------------------------------------- | -------- |
Comment thread
paper2 marked this conversation as resolved.
| Trace ID | `<owner>/<repo>:<run_id>:<run_attempt>` | 16 bytes |
| Workflow (root) span | `workflow:<run_id>:<run_attempt>` | 8 bytes |
| `<job>` span | `job:<job_id>` | 8 bytes |
| `<job> with time of waiting runner` | `job:<job_id>:with-waiting` | 8 bytes |
| `waiting runner for <job>` | `job:<job_id>:waiting` | 8 bytes |

Step spans keep random IDs. For example, to parent a span under a job in shell:

```bash
trace_id=$(printf '%s' "$GITHUB_REPOSITORY:$RUN_ID:$RUN_ATTEMPT" | sha256sum | cut -c1-32)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example does not hash the same key the implementation uses.

workflowTraceId is SHA-256 of repository + : + the workflow run id + : + run_attempt (GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT, not the workflow definition id). jobSpanId is SHA-256 of job: + the numeric Actions job id from the jobs API.

$RUN_ID, $RUN_ATTEMPT, and $JOB_ID are not set by GitHub. GITHUB_JOB / github.job is the YAML job key (build), not that numeric id. Hashing the YAML key, or an empty JOB_ID, produces a different span id, so a child span will not land on this action's job span. The table's <job_id> has the same ambiguity.

Use $GITHUB_REPOSITORY:$GITHUB_RUN_ID:$GITHUB_RUN_ATTEMPT, and job:$JOB_ID only after defining JOB_ID as the numeric job id (jobs[].id; on github.com and GHEC that is ${{ job.check_run_id }}, not github.job). printf '%s' | sha256sum | cut -c1-32 / cut -c1-16 already matches hashId.

parent_span_id=$(printf '%s' "job:$JOB_ID" | sha256sum | cut -c1-16)
```

## How it works

This action creates metrics and traces of GitHub Actions workflows and sends
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ inputs:
GITHUB_TOKEN:
description: 'GitHub token for client authentication'
required: true
DETERMINISTIC_TRACE_IDS:
description:
'Derive the trace ID from the workflow run, and job span IDs from job IDs,
so other tools can attach spans to the trace. See README.'
required: false
default: 'false'

runs:
using: node24
Expand Down
67 changes: 57 additions & 10 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/instrumentation/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import settings from '../settings.js'
import { idGenerator } from '../traces/span-ids.js'

let traceProvider: BasicTracerProvider
let meterProvider: MeterProvider
Expand Down Expand Up @@ -57,6 +58,7 @@ const initializeMeter = (exporter?: PushMetricExporter): void => {
const initializeTracer = (exporter?: SpanExporter): void => {
if (settings.FeatureFlagTrace) {
traceProvider = new BasicTracerProvider({
idGenerator,
resource: detectResources({ detectors: [envDetector] }),
spanProcessors: [
new BatchSpanProcessor(exporter || new OTLPTraceExporter({}))
Expand Down
2 changes: 2 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const createSettings = (env: typeof process.env) => ({
FeatureFlagMetrics: env.FEATURE_METRICS
? env.FEATURE_METRICS.toLowerCase() === 'true'
: true,
deterministicTraceIds:
env.INPUT_DETERMINISTIC_TRACE_IDS?.toLowerCase() === 'true',
logeLevel:
env.RUNNER_DEBUG === '1'
? 'debug' // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
Expand Down
37 changes: 31 additions & 6 deletions src/traces/create-spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import * as opentelemetry from '@opentelemetry/api'
import { calcDiffSec } from '../utils/calc-diff-sec.js'
import * as core from '@actions/core'
import { Workflow } from '../github/types.js'
import settings from '../settings.js'
import {
SpanIds,
idGenerator,
jobSpanId,
jobWaitingSpanId,
jobWithWaitingSpanId,
workflowSpanId,
workflowTraceId
} from './span-ids.js'

export const createWorkflowTrace = (
workflow: Workflow,
Expand All @@ -19,7 +29,15 @@ export const createWorkflowTrace = (
workflow.created_at,
getLatestCompletedAt(workflowJobs),
workflow.conclusion || '', // '' is converted to UNSET status. we should not use ''.
{ ...buildWorkflowAttributes(workflow) }
{ ...buildWorkflowAttributes(workflow) },
{
traceId: workflowTraceId(
workflow.repository.full_name,
workflow.id,
workflow.run_attempt
),
spanId: workflowSpanId(workflow.id, workflow.run_attempt)
}
)

return opentelemetry.trace.setSpan(ROOT_CONTEXT, span)
Expand All @@ -41,7 +59,8 @@ export const createWorkflowJobSpan = (
job.created_at,
job.completed_at,
job.conclusion,
{ ...buildWorkflowJobAttributes(job) }
{ ...buildWorkflowJobAttributes(job) },
{ spanId: jobWithWaitingSpanId(job.id) }
)
const ctxWithWaiting = opentelemetry.trace.setSpan(ctx, spanWithWaiting)

Expand All @@ -54,7 +73,8 @@ export const createWorkflowJobSpan = (
job.created_at,
job.started_at,
'success', // waiting runner is not a error.
{ ...buildWorkflowJobAttributes(job) }
{ ...buildWorkflowJobAttributes(job) },
{ spanId: jobWaitingSpanId(job.id) }
)
} else {
core.notice(
Expand All @@ -68,7 +88,8 @@ export const createWorkflowJobSpan = (
job.started_at,
job.completed_at,
job.conclusion,
{ ...buildWorkflowJobAttributes(job) }
{ ...buildWorkflowJobAttributes(job) },
{ spanId: jobSpanId(job.id) }
)

return opentelemetry.trace.setSpan(ctxWithWaiting, jobSpan)
Expand Down Expand Up @@ -103,10 +124,14 @@ const createSpan = (
endAt: Date,
// TODO: use user defined type instead of string
conclusion: string,
attributes: opentelemetry.Attributes
attributes: opentelemetry.Attributes,
ids: SpanIds = {}
): opentelemetry.Span => {
const tracer = opentelemetry.trace.getTracer('github-actions-opentelemetry')
const span = tracer.startSpan(name, { startTime: startAt, attributes }, ctx)
const span = idGenerator.withIds(
settings.deterministicTraceIds ? ids : {},
() => tracer.startSpan(name, { startTime: startAt, attributes }, ctx)
)
span.setStatus(getSpanStatusFromConclusion(conclusion))
span.end(endAt)
return span
Expand Down
26 changes: 26 additions & 0 deletions src/traces/create-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,32 @@ describe('should export expected spans', () => {
expect(traceId).toBe(rootSpan.spanContext().traceId)
})

test('should keep random IDs unless deterministic IDs are enabled', async () => {
const traceId = await createTrace(workflowRunResults)
expect(traceId).toMatch(/^[0-9a-f]{32}$/)
expect(traceId).not.toBe('9b622c3f42e4e04dcc07e34075e07913')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nits] It should be wrote :)

Suggested change
expect(traceId).not.toBe('9b622c3f42e4e04dcc07e34075e07913')
// Hard-coded: these are the documented contract other tools compute against.
expect(traceId).not.toBe('9b622c3f42e4e04dcc07e34075e07913')

})

test('should derive trace and job span IDs from the run and job IDs', async () => {
settings.deterministicTraceIds = true
const traceId = await createTrace(workflowRunResults)
await forceFlush()
settings.deterministicTraceIds = false

// Hard-coded: these are the documented contract other tools compute against.
expect(traceId).toBe('9b622c3f42e4e04dcc07e34075e07913')
const spans = exporter.getFinishedSpans()
const spanId = (name: string): string =>
findSpanByName(spans, name).spanContext().spanId
expect(spanId(workflowRun.name)).toBe('a20d690c4ccb758d')
expect(spanId('job1')).toBe('d45e31f76cac183d')
expect(spanId('job1 with time of waiting runner')).toBe('df18a27dfc12d482')
expect(spanId('waiting runner for job1')).toBe('4a0b581f5a1cf0af')
expect(new Set(spans.map(span => span.spanContext().spanId)).size).toBe(
spans.length
)
})

test('should return empty string when trace feature is disabled', async () => {
settings.FeatureFlagTrace = false
const traceId = await createTrace(workflowRunResults)
Expand Down
55 changes: 55 additions & 0 deletions src/traces/span-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createHash } from 'node:crypto'
import { IdGenerator, RandomIdGenerator } from '@opentelemetry/sdk-trace-base'

export type SpanIds = { traceId?: string; spanId?: string }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[imo] SpanIds reads like a collection of span IDs, but this is the trace ID and span ID preset for a single startSpan. PresetIds matches withIds / preset.


const hashId = (key: string, bytes: number): string =>
createHash('sha256')
.update(key)
.digest('hex')
.slice(0, bytes * 2)

// Deterministic so other tools can attach spans to a workflow run's trace
// without talking to this action. See "Deterministic trace IDs" in README.md.
export const workflowTraceId = (
repository: string,
runId: number,
runAttempt: number
): string => hashId(`${repository}:${runId}:${runAttempt}`, 16)

export const workflowSpanId = (runId: number, runAttempt: number): string =>
hashId(`workflow:${runId}:${runAttempt}`, 8)

export const jobSpanId = (jobId: number): string => hashId(`job:${jobId}`, 8)

export const jobWithWaitingSpanId = (jobId: number): string =>
hashId(`job:${jobId}:with-waiting`, 8)

export const jobWaitingSpanId = (jobId: number): string =>
hashId(`job:${jobId}:waiting`, 8)

// The tracer asks the provider's IdGenerator for ids without saying which span
// they are for, so createSpan presets them immediately around startSpan.
class PresetIdGenerator implements IdGenerator {
private readonly random = new RandomIdGenerator()
private preset: SpanIds = {}

withIds<T>(ids: SpanIds, start: () => T): T {
this.preset = ids
try {
return start()
} finally {
this.preset = {}
}
}

generateTraceId(): string {
return this.preset.traceId ?? this.random.generateTraceId()
}

generateSpanId(): string {
return this.preset.spanId ?? this.random.generateSpanId()
}
}

export const idGenerator = new PresetIdGenerator()
Loading