1
0
Fork 0
mirror of https://github.com/imjasonh/gcp-metrics-action synced 2026-07-22 07:30:11 +00:00

fix job conclusion, remove step.total

Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
Jason Hall 2025-11-05 16:06:56 -05:00
parent 6cbadacc27
commit 69a4789f22
7 changed files with 217 additions and 55 deletions

View file

@ -247,11 +247,6 @@ steps:
- `step.status` - Status (completed, in_progress, etc.)
- `step.conclusion` - Conclusion (success, failure, skipped, etc.)
#### Step Count
- **Metric:** `github.actions.step.total`
- **Type:** Counter
- **Labels:** Same as step duration
### Traces
The action creates distributed traces showing the execution timeline of your workflow:
@ -286,7 +281,6 @@ Metrics will appear in Google Cloud Monitoring under custom metrics:
3. Available metrics:
- `custom.googleapis.com/github.actions/job.duration`
- `custom.googleapis.com/github.actions/step.duration`
- `custom.googleapis.com/github.actions/step.total`
![Metrics in Cloud Monitoring](metrics.png)
@ -300,11 +294,11 @@ custom.googleapis.com/github.actions/step.duration
| mean
```
**Job failure rate:**
**Job failure rate over time:**
```
custom.googleapis.com/github.actions/step.total
| filter metric.step.conclusion = "failure"
| rate(1m)
custom.googleapis.com/github.actions/job.duration
| filter metric.job.conclusion = "failure"
| group_by [], .rate(1h)
```
**Metrics for a specific PR:**

9
dist/index.js vendored
View file

@ -366,12 +366,6 @@ function recordMetrics(meter, metrics, metricPrefix) {
});
core.debug(`Created histogram metric: ${stepDurationName}`);
const stepCounterName = `${metricPrefix}.step.total`;
const stepCounter = meter.createCounter(stepCounterName, {
description: 'Total count of workflow steps by conclusion',
});
core.debug(`Created counter metric: ${stepCounterName}`);
// Record job-level metrics (always record, even if job not completed yet)
const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, {
description: 'Duration of workflow jobs in milliseconds',
@ -401,9 +395,6 @@ function recordMetrics(meter, metrics, metricPrefix) {
stepDurationHistogram.record(step.durationMs, stepAttributes);
core.info(`Recorded step "${step.name}" duration: ${step.durationMs}ms`);
}
// Record step count (for success/failure tracking)
stepCounter.add(1, stepAttributes);
}
core.info(`Recorded metrics for ${metrics.steps.length} steps`);

34
dist/post/index.js vendored
View file

@ -74,6 +74,27 @@ async function collectMetrics(octokit, context) {
core.debug(`Job not marked complete yet, estimating duration using current time: ${jobDurationMs}ms`);
}
// Infer job conclusion from steps if not set (post-action runs before job completes)
let jobConclusion = job.conclusion;
if (!jobConclusion || jobConclusion === 'unknown') {
const hasFailure = steps.some(s => s.conclusion === 'failure');
const hasCancelled = steps.some(s => s.conclusion === 'cancelled');
if (hasFailure) {
jobConclusion = 'failure';
core.debug('Inferred job conclusion as "failure" based on failed steps');
} else if (hasCancelled) {
jobConclusion = 'cancelled';
core.debug('Inferred job conclusion as "cancelled" based on cancelled steps');
} else if (steps.length > 0 && steps.every(s => s.conclusion === 'success' || s.conclusion === 'skipped')) {
jobConclusion = 'success';
core.debug('Inferred job conclusion as "success" based on step results');
} else {
jobConclusion = 'unknown';
core.debug('Could not infer job conclusion from steps');
}
}
// Extract PR number if this is a pull request event
const prNumber = context.payload?.pull_request?.number ||
(process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]) ||
@ -84,8 +105,8 @@ async function collectMetrics(octokit, context) {
job: {
name: job.name,
id: job.id,
status: job.status,
conclusion: job.conclusion,
status: job.status || 'in_progress',
conclusion: jobConclusion,
startedAt: jobStartedAt,
completedAt: jobCompletedAt,
durationMs: jobDurationMs,
@ -493,12 +514,6 @@ function recordMetrics(meter, metrics, metricPrefix) {
});
core.debug(`Created histogram metric: ${stepDurationName}`);
const stepCounterName = `${metricPrefix}.step.total`;
const stepCounter = meter.createCounter(stepCounterName, {
description: 'Total count of workflow steps by conclusion',
});
core.debug(`Created counter metric: ${stepCounterName}`);
// Record job-level metrics (always record, even if job not completed yet)
const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, {
description: 'Duration of workflow jobs in milliseconds',
@ -528,9 +543,6 @@ function recordMetrics(meter, metrics, metricPrefix) {
stepDurationHistogram.record(step.durationMs, stepAttributes);
core.info(`Recorded step "${step.name}" duration: ${step.durationMs}ms`);
}
// Record step count (for success/failure tracking)
stepCounter.add(1, stepAttributes);
}
core.info(`Recorded metrics for ${metrics.steps.length} steps`);

View file

@ -68,6 +68,27 @@ async function collectMetrics(octokit, context) {
core.debug(`Job not marked complete yet, estimating duration using current time: ${jobDurationMs}ms`);
}
// Infer job conclusion from steps if not set (post-action runs before job completes)
let jobConclusion = job.conclusion;
if (!jobConclusion || jobConclusion === 'unknown') {
const hasFailure = steps.some(s => s.conclusion === 'failure');
const hasCancelled = steps.some(s => s.conclusion === 'cancelled');
if (hasFailure) {
jobConclusion = 'failure';
core.debug('Inferred job conclusion as "failure" based on failed steps');
} else if (hasCancelled) {
jobConclusion = 'cancelled';
core.debug('Inferred job conclusion as "cancelled" based on cancelled steps');
} else if (steps.length > 0 && steps.every(s => s.conclusion === 'success' || s.conclusion === 'skipped')) {
jobConclusion = 'success';
core.debug('Inferred job conclusion as "success" based on step results');
} else {
jobConclusion = 'unknown';
core.debug('Could not infer job conclusion from steps');
}
}
// Extract PR number if this is a pull request event
const prNumber = context.payload?.pull_request?.number ||
(process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]) ||
@ -78,8 +99,8 @@ async function collectMetrics(octokit, context) {
job: {
name: job.name,
id: job.id,
status: job.status,
conclusion: job.conclusion,
status: job.status || 'in_progress',
conclusion: jobConclusion,
startedAt: jobStartedAt,
completedAt: jobCompletedAt,
durationMs: jobDurationMs,

View file

@ -106,12 +106,6 @@ function recordMetrics(meter, metrics, metricPrefix) {
});
core.debug(`Created histogram metric: ${stepDurationName}`);
const stepCounterName = `${metricPrefix}.step.total`;
const stepCounter = meter.createCounter(stepCounterName, {
description: 'Total count of workflow steps by conclusion',
});
core.debug(`Created counter metric: ${stepCounterName}`);
// Record job-level metrics (always record, even if job not completed yet)
const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, {
description: 'Duration of workflow jobs in milliseconds',
@ -141,9 +135,6 @@ function recordMetrics(meter, metrics, metricPrefix) {
stepDurationHistogram.record(step.durationMs, stepAttributes);
core.info(`Recorded step "${step.name}" duration: ${step.durationMs}ms`);
}
// Record step count (for success/failure tracking)
stepCounter.add(1, stepAttributes);
}
core.info(`Recorded metrics for ${metrics.steps.length} steps`);

View file

@ -171,4 +171,167 @@ test('collectMetrics', async (t) => {
/API Error/
);
});
await t.test('should infer job conclusion as failure from failed steps', async () => {
const mockJobData = {
jobs: [
{
id: 12345,
name: 'test-job',
status: 'in_progress',
conclusion: null,
started_at: '2025-01-01T10:00:00Z',
completed_at: null,
steps: [
{
name: 'Step 1',
number: 1,
status: 'completed',
conclusion: 'success',
started_at: '2025-01-01T10:00:00Z',
completed_at: '2025-01-01T10:01:00Z',
},
{
name: 'Step 2',
number: 2,
status: 'completed',
conclusion: 'failure',
started_at: '2025-01-01T10:01:00Z',
completed_at: '2025-01-01T10:02:00Z',
},
],
},
],
};
const mockOctokit = {
rest: {
actions: {
listJobsForWorkflowRun: mock.fn(async () => ({ data: mockJobData })),
},
},
};
const mockContext = {
repo: { owner: 'test-owner', repo: 'test-repo' },
runId: 67890,
runNumber: 42,
workflow: 'CI',
};
process.env.GITHUB_JOB = 'test-job';
const metrics = await collectMetrics(mockOctokit, mockContext);
assert.strictEqual(metrics.job.conclusion, 'failure');
assert.strictEqual(metrics.job.status, 'in_progress');
});
await t.test('should infer job conclusion as success from successful steps', async () => {
const mockJobData = {
jobs: [
{
id: 12345,
name: 'test-job',
status: 'in_progress',
conclusion: null,
started_at: '2025-01-01T10:00:00Z',
completed_at: null,
steps: [
{
name: 'Step 1',
number: 1,
status: 'completed',
conclusion: 'success',
started_at: '2025-01-01T10:00:00Z',
completed_at: '2025-01-01T10:01:00Z',
},
{
name: 'Step 2',
number: 2,
status: 'completed',
conclusion: 'success',
started_at: '2025-01-01T10:01:00Z',
completed_at: '2025-01-01T10:02:00Z',
},
],
},
],
};
const mockOctokit = {
rest: {
actions: {
listJobsForWorkflowRun: mock.fn(async () => ({ data: mockJobData })),
},
},
};
const mockContext = {
repo: { owner: 'test-owner', repo: 'test-repo' },
runId: 67890,
runNumber: 42,
workflow: 'CI',
};
process.env.GITHUB_JOB = 'test-job';
const metrics = await collectMetrics(mockOctokit, mockContext);
assert.strictEqual(metrics.job.conclusion, 'success');
});
await t.test('should infer job conclusion as cancelled from cancelled steps', async () => {
const mockJobData = {
jobs: [
{
id: 12345,
name: 'test-job',
status: 'in_progress',
conclusion: null,
started_at: '2025-01-01T10:00:00Z',
completed_at: null,
steps: [
{
name: 'Step 1',
number: 1,
status: 'completed',
conclusion: 'success',
started_at: '2025-01-01T10:00:00Z',
completed_at: '2025-01-01T10:01:00Z',
},
{
name: 'Step 2',
number: 2,
status: 'completed',
conclusion: 'cancelled',
started_at: '2025-01-01T10:01:00Z',
completed_at: '2025-01-01T10:02:00Z',
},
],
},
],
};
const mockOctokit = {
rest: {
actions: {
listJobsForWorkflowRun: mock.fn(async () => ({ data: mockJobData })),
},
},
};
const mockContext = {
repo: { owner: 'test-owner', repo: 'test-repo' },
runId: 67890,
runNumber: 42,
workflow: 'CI',
};
process.env.GITHUB_JOB = 'test-job';
const metrics = await collectMetrics(mockOctokit, mockContext);
assert.strictEqual(metrics.job.conclusion, 'cancelled');
});
});

View file

@ -137,23 +137,15 @@ test('recordMetrics', async (t) => {
// Verify histogram creation for job and step duration
assert.strictEqual(mockMeter.createHistogram.mock.calls.length >= 2, true);
// Verify counter creation for step totals
assert.strictEqual(mockMeter.createCounter.mock.calls.length >= 1, true);
// Verify histogram records were called (1 job + 2 steps = 3)
assert.strictEqual(mockHistogramRecord.mock.calls.length, 3);
// Verify counter was called for each step
assert.strictEqual(mockCounterAdd.mock.calls.length, 2);
});
await t.test('should handle steps with zero duration', () => {
const mockHistogramRecord = mock.fn();
const mockCounterAdd = mock.fn();
const mockMeter = {
createHistogram: mock.fn(() => ({ record: mockHistogramRecord })),
createCounter: mock.fn(() => ({ add: mockCounterAdd })),
};
const metrics = {
@ -200,19 +192,17 @@ test('recordMetrics', async (t) => {
recordMetrics(mockMeter, metrics, 'test.prefix');
// Job duration is 0, so it should not be recorded
// Step has 0 duration, so histogram should not record it
// But counter should still be called for the step
assert.strictEqual(mockCounterAdd.mock.calls.length, 1);
// Job duration is always recorded, even if 0
// Step has 0 duration, so it should not be recorded
// Total histogram calls: 1 (just the job)
assert.strictEqual(mockHistogramRecord.mock.calls.length, 1);
});
await t.test('should include correct attributes in metrics', () => {
const mockHistogramRecord = mock.fn();
const mockCounterAdd = mock.fn();
const mockMeter = {
createHistogram: mock.fn(() => ({ record: mockHistogramRecord })),
createCounter: mock.fn(() => ({ add: mockCounterAdd })),
};
const metrics = {