commit eb3bfbbb0c125641afeb053ec25036243b0a1f65 Author: Jason Hall Date: Wed Nov 5 10:39:50 2025 -0500 initial commit Signed-off-by: Jason Hall diff --git a/.github/workflows/use-action.yaml b/.github/workflows/use-action.yaml new file mode 100644 index 0000000..3d3c399 --- /dev/null +++ b/.github/workflows/use-action.yaml @@ -0,0 +1,32 @@ +name: Example Workflow with Metrics + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + example: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: ./ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + gcp-service-account-key: github-actions-metrics-key.json + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - run: npm install + - run: npm run lint || echo "No lint script configured" + - run: npm test + - run: echo "Building project..." && sleep 5 && echo "Build complete" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fad5b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Test coverage +coverage/ +.nyc_output/ + +# Temporary files +*.log +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1e8618 --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# OpenTelemetry Metrics Exporter for GitHub Actions + +A GitHub Action that collects workflow step metrics (duration, status) and exports them to Google Cloud Monitoring via OpenTelemetry. + +## Features + +- 📊 Collects job and step-level metrics from GitHub Actions workflows +- ⏱️ Records duration histograms for performance tracking +- ✅ Tracks success/failure rates with counters +- 🏷️ Rich metric labels (workflow, job, repository, run info) +- 🔒 Minimal permissions (Monitoring Metric Writer only) +- 🔄 Always runs (even when steps fail) + +## Setup + +### 1. Create a Google Cloud Service Account + +Create a service account with only the permission to write metrics: + +```bash +# Set your GCP project ID +PROJECT_ID="your-project-id" + +# Create the service account +gcloud iam service-accounts create github-actions-metrics \ + --display-name="GitHub Actions OpenTelemetry Metrics" \ + --description="Service account for GitHub Actions to write OpenTelemetry metrics to Cloud Monitoring" \ + --project="${PROJECT_ID}" + +# Grant the Monitoring Metric Writer role +gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ + --member="serviceAccount:github-actions-metrics@${PROJECT_ID}.iam.gserviceaccount.com" \ + --role="roles/monitoring.metricWriter" \ + --condition=None + +# Create and download a JSON key +gcloud iam service-accounts keys create github-actions-metrics-key.json \ + --iam-account="github-actions-metrics@${PROJECT_ID}.iam.gserviceaccount.com" +``` + +### 2. Add Service Account Key to GitHub Secrets + +1. Copy the contents of the JSON key file: + ```bash + cat github-actions-metrics-key.json + ``` + +2. In your GitHub repository, go to **Settings → Secrets and variables → Actions** + +3. Click **New repository secret** + +4. Name: `GCP_SERVICE_ACCOUNT_KEY` + +5. Value: Paste the entire JSON content + +6. Click **Add secret** + +7. Also add your GCP project ID as a secret: + - Name: `GCP_PROJECT_ID` + - Value: Your project ID (e.g., `jason-chainguard`) + +8. **Securely delete the key file:** + ```bash + rm github-actions-metrics-key.json + ``` + +## Usage + +### Basic Usage + +Add the action as one of the first steps in your workflow. The action will automatically collect metrics in a post-action phase after all other steps complete. + +```yaml +name: CI Pipeline + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Enable metrics collection + - name: Setup OpenTelemetry Metrics + uses: your-org/otel-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-service-account-key: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + + # Your regular workflow steps + - uses: actions/checkout@v4 + - name: Install dependencies + run: npm install + - name: Run tests + run: npm test + - name: Build + run: npm run build + + # Metrics are automatically collected and exported after this job completes +``` + +### Advanced Configuration + +```yaml +- name: Setup OpenTelemetry Metrics + uses: your-org/otel-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-service-account-key: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + + # Optional: Customize service name for resource attributes + service-name: 'my-app-ci' + + # Optional: Customize service namespace + service-namespace: 'production' + + # Optional: Customize metric name prefix + metric-prefix: 'ci.metrics' + + # Optional: Adjust export interval (milliseconds) + export-interval-millis: '5000' +``` + +## Metrics Collected + +### Job Duration +- **Metric:** `github.actions.job.duration` +- **Type:** Histogram +- **Unit:** milliseconds +- **Labels:** + - `workflow.name` - Name of the workflow + - `job.name` - Name of the job + - `job.status` - Status (completed, in_progress, etc.) + - `job.conclusion` - Conclusion (success, failure, cancelled, etc.) + - `repository.owner` - Repository owner + - `repository.name` - Repository name + - `repository.full_name` - Full repository name (owner/repo) + - `run.id` - Workflow run ID + - `run.number` - Workflow run number + - `run.attempt` - Run attempt number + +### Step Duration +- **Metric:** `github.actions.step.duration` +- **Type:** Histogram +- **Unit:** milliseconds +- **Labels:** All job labels plus: + - `step.name` - Name of the step + - `step.number` - Step number + - `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 + +## Viewing Metrics + +Metrics will appear in Google Cloud Monitoring under custom metrics: + +1. Go to **Cloud Console → Monitoring → Metrics Explorer** +2. Search for: `custom.googleapis.com/github.actions` +3. Available metrics: + - `custom.googleapis.com/github.actions/job.duration` + - `custom.googleapis.com/github.actions/step.duration` + - `custom.googleapis.com/github.actions/step.total` + +### Example Queries + +**Average step duration by step name:** +``` +custom.googleapis.com/github.actions/step.duration +| filter resource.project_id = "your-project-id" +| group_by [metric.step.name] +| mean +``` + +**Job failure rate:** +``` +custom.googleapis.com/github.actions/step.total +| filter metric.step.conclusion = "failure" +| rate(1m) +``` + +## Alternative: Using Workload Identity Federation + +Instead of service account keys, you can use Workload Identity Federation (recommended for production): + +```yaml +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - name: Setup OpenTelemetry Metrics + uses: your-org/otel-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + # No service account key needed! + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} + + # Your workflow steps... +``` + +## Development + +### Install Dependencies + +```bash +npm install +``` + +### Run Tests + +```bash +npm test +``` + +### Project Structure + +``` +otel-action/ +├── action.yml # Action definition +├── index.js # Main entry point (no-op) +├── post.js # Post-action (collects & exports metrics) +├── lib/ +│ ├── config.js # Configuration parsing +│ ├── collector.js # GitHub API metrics collection +│ └── exporter.js # OpenTelemetry export +└── test/ + ├── collector.test.js + └── exporter.test.js +``` + +## License + +Apache-2.0 + +## Contributing + +Issues and pull requests welcome! diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..ecb3265 --- /dev/null +++ b/action.yml @@ -0,0 +1,40 @@ +name: 'OpenTelemetry Metrics Exporter' +description: 'Collects GitHub Actions workflow step metrics and exports them to Google Cloud Monitoring via OpenTelemetry' +author: 'Jason' +branding: + icon: 'activity' + color: 'blue' + +inputs: + github-token: + description: 'GitHub token for API access (typically ${{ secrets.GITHUB_TOKEN }})' + required: true + default: ${{ github.token }} + gcp-project-id: + description: 'Google Cloud Project ID where metrics will be exported' + required: true + gcp-service-account-key: + description: 'Google Cloud service account key JSON (alternative to ADC/Workload Identity)' + required: false + service-name: + description: 'Service name for OpenTelemetry resource attributes' + required: false + default: 'github-actions' + service-namespace: + description: 'Service namespace for OpenTelemetry resource attributes' + required: false + default: 'ci' + metric-prefix: + description: 'Prefix for metric names' + required: false + default: 'github.actions' + export-interval-millis: + description: 'Export interval in milliseconds' + required: false + default: '5000' + +runs: + using: 'node20' + main: 'index.js' + post: 'post.js' + post-if: 'always()' diff --git a/github-actions-metrics-key.json b/github-actions-metrics-key.json new file mode 100644 index 0000000..f69a4a1 --- /dev/null +++ b/github-actions-metrics-key.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "jason-chainguard", + "private_key_id": "e36ddd0683fa66490fdd78eeb060e07aee38b315", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCW8n8nbT4eycYG\nrnk/cFvQuY4XB/zS/LeR4NhXlW06S1dsLQ0jcVm5DHyiScP+xZzK6hCMdftLVwrM\nUDoUcDKGa+2hvQYx7jy8XP9A7jAXLDgyG7xk6OPpKS3qvUy5kiZw37X0I/qTo7B7\nfn+7lTI4bjxmEhPHvtN8yk1VY4YgtZiGPzF0spW/NFkmNpKDpWdp9z/mmBkvA810\npnTAXyrH+fkbcyqpfw4f0PjkCegJPjL5XqxgvmfZGGDlIgae3HU1TmRO4xz0xSHN\n5MT1hHX2isb2zYEB6fX1S/WYTXFfjuZjFICTHHSjjQRDv57cmpKezdNjyE/fhC/0\nfMMoji8FAgMBAAECggEAIS7H4xvxxwTmQnvCbb6+gGD6Knf4DU2+8ROzm3Ve4KzF\nWCODOuJlLfffwjFjI7O/ZyATaT1ac9J7rjSIwAt2H/TD9YU/QmSz91ieSGDUM5Pl\n+qX8QVatG4mQ7YSVxEmrp66WKkhqgmA+oLdLTypaMuuQ3YNl08hQZ6N1YqsRRkkv\nA9Av5FO+d24gZYFqDm+2SGFZ0ON/hM2n7BwfoFJjVPvbW/egqCg7Q8Tp/Lfz3UH7\na25bovBTRDF+H1fxm0VFQZUiMqH7A1oOx9DXDrdViRTwjDOXI35J4nC5Gb+Ckej2\n5rfgdpxGYr8NyMhWLsSd3aYX7/0EA4yjZiJ6bPXY9QKBgQDEzO6GLfoeI40Tuny1\nMLxp4Blli1fQEVtTCgdaNC/TLu3fypyTZF/yRHkc9zdyqqGLYZVgZq77CRzX26zS\nK0P0gygGZUnGESGSN0s/FOvjZ/745UqU1yibkIGlurEt/F8m0CT2EnTt+wTo3ssr\nrE8qlIwltor5p4zTDoAZz4AGkwKBgQDEWofrJa0rcka8CU5eeP5WC4RLAi/FVaVE\nUEls8/TaMcUItGqvgszZFJMNay31jWsn01EfdbdkoSEuuCUjVgexUJuuv5t6Xke+\n0iact/N1CO53PvHU2jfLpCl5UAzzED+aDRo8rs41oXOlMmrUHhEl3PIrO2S/qx3Q\n8+J1uIGbBwKBgQCfzUhh7DONBZEo0+Uvu8NCtZXpvpwvd+iQJTSTKo0rDpBZiExb\n4sWGE9PEkXmUwrkqVLLW9IphjaS+IsLSZg9oHJgDmXuuOXrpH70aHALvRSLlOEq3\nUX7H0y9zQ4VbsfIRsjJRHeuU0p/J+B9B90jRao3ikbeHwWW4e63JR5TNwQKBgQCf\n5Pj5y9yQnKZtSUbAN9clSouYaVdtYqkKUDb6uk6RwCWrSP/3nJQj0lVNgBIU++xe\nqf5NmBaXo37aBKp5c/0fr3yXeQCrTUsvYQKAbGucyoEmKePUaT82XaBIYZ+p73lb\ngX/0GoVhtu/tfnjv8uwT4TBzdBI+4qGNHo0zP+SK0QKBgQCFtFtMxi2t665J7tN9\nJpIb4tHvP3DFBWSSegDtUl7LFycEoovOnedkHZDKF+a48tES3GIF5fIquoLYaDxN\nU2AqY6exjmebNvD9kHpK4vD7wFIHgv+LhMDoQ1LyQmv7hXM/ZuiUUtAJoobIa1iV\n3sxuDvENbH68yOjvzkXghcfG6Q==\n-----END PRIVATE KEY-----\n", + "client_email": "github-actions-metrics@jason-chainguard.iam.gserviceaccount.com", + "client_id": "107797850607299994698", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/github-actions-metrics%40jason-chainguard.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..9fdbe7e --- /dev/null +++ b/index.js @@ -0,0 +1,16 @@ +const core = require('@actions/core'); + +/** + * Main action entry point + * This action performs its work in the post-action phase + */ +async function run() { + try { + core.info('OpenTelemetry metrics collection action initialized'); + core.info('Metrics will be collected and exported after the job completes'); + } catch (error) { + core.setFailed(`Action failed: ${error.message}`); + } +} + +run(); diff --git a/lib/collector.js b/lib/collector.js new file mode 100644 index 0000000..32017b6 --- /dev/null +++ b/lib/collector.js @@ -0,0 +1,97 @@ +const github = require('@actions/github'); +const core = require('@actions/core'); + +/** + * Collects metrics from GitHub Actions workflow + * @param {Object} octokit - Authenticated Octokit instance + * @param {Object} context - GitHub context + * @returns {Promise} Collected metrics + */ +async function collectMetrics(octokit, context) { + const { owner, repo } = context.repo; + const runId = context.runId; + + core.info(`Collecting metrics for run ${runId} in ${owner}/${repo}`); + + try { + // Fetch job information for the current run + const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: runId, + }); + + core.debug(`Found ${jobs.jobs.length} jobs`); + + // Find the current job by matching the job name or ID + // GitHub Actions doesn't expose the current job ID directly, so we try to match by name + const currentJobName = process.env.GITHUB_JOB; + const currentJob = jobs.jobs.find(job => job.name === currentJobName); + + if (!currentJob) { + core.warning(`Could not find current job "${currentJobName}". Using first job as fallback.`); + } + + const job = currentJob || jobs.jobs[0]; + + if (!job) { + throw new Error('No jobs found for this workflow run'); + } + + core.info(`Analyzing job: ${job.name} (${job.id})`); + + // Parse steps and calculate durations + const steps = (job.steps || []).map(step => { + const startedAt = step.started_at ? new Date(step.started_at) : null; + const completedAt = step.completed_at ? new Date(step.completed_at) : null; + const durationMs = startedAt && completedAt ? completedAt - startedAt : 0; + + return { + name: step.name, + status: step.status, + conclusion: step.conclusion, + number: step.number, + startedAt, + completedAt, + durationMs, + }; + }); + + // Calculate job duration + const jobStartedAt = job.started_at ? new Date(job.started_at) : null; + const jobCompletedAt = job.completed_at ? new Date(job.completed_at) : null; + const jobDurationMs = jobStartedAt && jobCompletedAt ? jobCompletedAt - jobStartedAt : 0; + + const metrics = { + workflow: context.workflow, + job: { + name: job.name, + id: job.id, + status: job.status, + conclusion: job.conclusion, + startedAt: jobStartedAt, + completedAt: jobCompletedAt, + durationMs: jobDurationMs, + }, + steps, + repository: { + owner, + repo, + fullName: `${owner}/${repo}`, + }, + run: { + id: runId, + number: context.runNumber, + attempt: process.env.GITHUB_RUN_ATTEMPT || '1', + }, + }; + + core.debug(`Collected metrics: ${JSON.stringify(metrics, null, 2)}`); + return metrics; + } catch (error) { + core.error(`Failed to collect metrics: ${error.message}`); + throw error; + } +} + +module.exports = { collectMetrics }; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..1780916 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,36 @@ +const core = require('@actions/core'); + +/** + * Parses and validates action configuration from inputs + * @returns {Object} Configuration object + */ +function getConfig() { + const serviceAccountKey = core.getInput('gcp-service-account-key'); + + const config = { + gcpProjectId: core.getInput('gcp-project-id', { required: true }), + gcpServiceAccountKey: serviceAccountKey || null, + serviceName: core.getInput('service-name') || 'github-actions', + serviceNamespace: core.getInput('service-namespace') || 'ci', + metricPrefix: core.getInput('metric-prefix') || 'github.actions', + exportIntervalMillis: parseInt(core.getInput('export-interval-millis') || '5000', 10), + }; + + // Validate configuration + if (!config.gcpProjectId) { + throw new Error('gcp-project-id is required'); + } + + if (config.exportIntervalMillis < 1000) { + core.warning('export-interval-millis is too low, setting to 1000ms'); + config.exportIntervalMillis = 1000; + } + + // Log config without sensitive data + const safeConfig = { ...config, gcpServiceAccountKey: config.gcpServiceAccountKey ? '[REDACTED]' : null }; + core.debug(`Configuration: ${JSON.stringify(safeConfig, null, 2)}`); + + return config; +} + +module.exports = { getConfig }; diff --git a/lib/exporter.js b/lib/exporter.js new file mode 100644 index 0000000..caa9f61 --- /dev/null +++ b/lib/exporter.js @@ -0,0 +1,143 @@ +const core = require('@actions/core'); +const { MeterProvider, PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics'); +const { MetricExporter } = require('@google-cloud/opentelemetry-cloud-monitoring-exporter'); +const { Resource } = require('@opentelemetry/resources'); +const { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE, ATTR_SERVICE_INSTANCE_ID } = require('@opentelemetry/semantic-conventions'); + +/** + * Creates and configures an OpenTelemetry MeterProvider with GCP exporter + * @param {Object} config - Configuration object + * @returns {Object} MeterProvider and meters + */ +function createMeterProvider(config) { + core.info('Initializing OpenTelemetry MeterProvider with Google Cloud Monitoring exporter'); + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: config.serviceName, + [ATTR_SERVICE_NAMESPACE]: config.serviceNamespace, + [ATTR_SERVICE_INSTANCE_ID]: process.env.GITHUB_RUN_ID || 'unknown', + }); + + // Configure exporter options + const exporterOptions = { + projectId: config.gcpProjectId, + }; + + // If service account key is provided, parse and use it + if (config.gcpServiceAccountKey) { + try { + const credentials = JSON.parse(config.gcpServiceAccountKey); + exporterOptions.credentials = credentials; + core.info('Using provided service account credentials'); + } catch (error) { + core.error(`Failed to parse service account key: ${error.message}`); + throw new Error('Invalid service account key JSON'); + } + } else { + core.info('Using Application Default Credentials'); + } + + const exporter = new MetricExporter(exporterOptions); + + const metricReader = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: config.exportIntervalMillis, + }); + + const meterProvider = new MeterProvider({ + resource, + readers: [metricReader], + }); + + const meter = meterProvider.getMeter(config.metricPrefix); + + return { meterProvider, meter }; +} + +/** + * Records metrics for collected workflow data + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics from GitHub + * @param {string} metricPrefix - Metric name prefix + */ +function recordMetrics(meter, metrics, metricPrefix) { + core.info('Recording metrics to OpenTelemetry'); + + const baseAttributes = { + 'workflow.name': metrics.workflow, + 'job.name': metrics.job.name, + 'repository.owner': metrics.repository.owner, + 'repository.name': metrics.repository.repo, + 'repository.full_name': metrics.repository.fullName, + 'run.id': metrics.run.id.toString(), + 'run.number': metrics.run.number.toString(), + 'run.attempt': metrics.run.attempt, + }; + + // Create meters for step metrics + const stepDurationHistogram = meter.createHistogram(`${metricPrefix}.step.duration`, { + description: 'Duration of workflow steps in milliseconds', + unit: 'ms', + }); + + const stepCounter = meter.createCounter(`${metricPrefix}.step.total`, { + description: 'Total count of workflow steps by conclusion', + }); + + // Record job-level metrics + if (metrics.job.durationMs > 0) { + const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, { + description: 'Duration of workflow jobs in milliseconds', + unit: 'ms', + }); + + jobDurationHistogram.record(metrics.job.durationMs, { + ...baseAttributes, + 'job.status': metrics.job.status, + 'job.conclusion': metrics.job.conclusion || 'unknown', + }); + + core.info(`Recorded job duration: ${metrics.job.durationMs}ms`); + } + + // Record step-level metrics + for (const step of metrics.steps) { + const stepAttributes = { + ...baseAttributes, + 'step.name': step.name, + 'step.number': step.number.toString(), + 'step.status': step.status, + 'step.conclusion': step.conclusion || 'unknown', + }; + + // Record step duration + if (step.durationMs > 0) { + 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`); +} + +/** + * Forces metrics export and shuts down the meter provider + * @param {Object} meterProvider - MeterProvider instance + * @returns {Promise} + */ +async function shutdown(meterProvider) { + core.info('Flushing and shutting down MeterProvider'); + try { + await meterProvider.forceFlush(); + await meterProvider.shutdown(); + core.info('MeterProvider shut down successfully'); + } catch (error) { + core.error(`Error during shutdown: ${error.message}`); + throw error; + } +} + +module.exports = { createMeterProvider, recordMetrics, shutdown }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c7f3d3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,920 @@ +{ + "name": "otel-action", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "otel-action", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.19.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/resources": "^1.28.0", + "@opentelemetry/sdk-metrics": "^1.28.0", + "@opentelemetry/semantic-conventions": "^1.28.0" + }, + "devDependencies": {} + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.1.tgz", + "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==", + "dependencies": { + "@actions/http-client": "^2.2.0", + "@octokit/core": "^5.0.1", + "@octokit/plugin-paginate-rest": "^9.2.2", + "@octokit/plugin-rest-endpoint-methods": "^10.4.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "undici": "^5.28.5" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-monitoring-exporter/-/opentelemetry-cloud-monitoring-exporter-0.19.0.tgz", + "integrity": "sha512-5SOPXwC6RET4ZvXxw5D97dp8fWpqWEunHrzrUUGXhG4UAeedQe1KvYV8CK+fnaAbN2l2ha6QDYspT6z40TVY0g==", + "dependencies": { + "@google-cloud/opentelemetry-resource-util": "^2.3.0", + "@google-cloud/precise-date": "^4.0.0", + "google-auth-library": "^9.0.0", + "googleapis": "^137.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@opentelemetry/core": "^1.0.0", + "@opentelemetry/resources": "^1.0.0", + "@opentelemetry/sdk-metrics": "^1.0.0" + } + }, + "node_modules/@google-cloud/opentelemetry-resource-util": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-resource-util/-/opentelemetry-resource-util-2.4.0.tgz", + "integrity": "sha512-/7ujlMoKtDtrbQlJihCjQnm31n2s2RTlvJqcSbt2jV3OkCzPAdo3u31Q13HNugqtIRUSk7bUoLx6AzhURkhW4w==", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.22.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/resources": "^1.0.0" + } + }, + "node_modules/@google-cloud/precise-date": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/precise-date/-/precise-date-4.0.0.tgz", + "integrity": "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz", + "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", + "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz", + "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", + "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..61a55f8 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "otel-action", + "version": "1.0.0", + "description": "GitHub Action that collects workflow step metrics and exports them to OpenTelemetry/Google Cloud Monitoring", + "main": "index.js", + "scripts": { + "test": "node --test test/collector.test.js test/exporter.test.js" + }, + "keywords": [ + "github-actions", + "opentelemetry", + "metrics", + "google-cloud-monitoring" + ], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.19.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/resources": "^1.28.0", + "@opentelemetry/sdk-metrics": "^1.28.0", + "@opentelemetry/semantic-conventions": "^1.28.0" + }, + "devDependencies": {} +} diff --git a/post.js b/post.js new file mode 100644 index 0000000..b7878b7 --- /dev/null +++ b/post.js @@ -0,0 +1,55 @@ +const core = require('@actions/core'); +const github = require('@actions/github'); +const { getConfig } = require('./lib/config'); +const { collectMetrics } = require('./lib/collector'); +const { createMeterProvider, recordMetrics, shutdown } = require('./lib/exporter'); + +/** + * Post-action entry point + * Collects workflow metrics and exports them to Google Cloud Monitoring + */ +async function run() { + let meterProvider; + + try { + core.info('Starting OpenTelemetry metrics collection post-action'); + + // Get configuration + const config = getConfig(); + + // Get GitHub token and create Octokit client + const token = core.getInput('github-token', { required: true }); + const octokit = github.getOctokit(token); + + // Collect metrics from GitHub API + const metrics = await collectMetrics(octokit, github.context); + + // Initialize OpenTelemetry and export metrics + const { meterProvider: provider, meter } = createMeterProvider(config); + meterProvider = provider; + + recordMetrics(meter, metrics, config.metricPrefix); + + // Force flush and shutdown to ensure metrics are exported + await shutdown(meterProvider); + + core.info('Metrics successfully exported to Google Cloud Monitoring'); + } catch (error) { + core.error(`Post-action failed: ${error.message}`); + core.error(error.stack); + + // Try to shutdown gracefully even on error + if (meterProvider) { + try { + await shutdown(meterProvider); + } catch (shutdownError) { + core.error(`Error during shutdown: ${shutdownError.message}`); + } + } + + // Don't fail the workflow if metrics export fails + core.warning('Metrics export failed, but workflow will continue'); + } +} + +run(); diff --git a/test/collector.test.js b/test/collector.test.js new file mode 100644 index 0000000..c51fd6d --- /dev/null +++ b/test/collector.test.js @@ -0,0 +1,174 @@ +const { test, mock } = require('node:test'); +const assert = require('node:assert'); +const { collectMetrics } = require('../lib/collector'); + +test('collectMetrics', async (t) => { + await t.test('should collect metrics from GitHub API', async () => { + const mockJobData = { + jobs: [ + { + id: 12345, + name: 'test-job', + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:00:00Z', + completed_at: '2025-01-01T10:05:00Z', + steps: [ + { + name: 'Checkout', + number: 1, + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:00:00Z', + completed_at: '2025-01-01T10:01:00Z', + }, + { + name: 'Build', + number: 2, + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:01:00Z', + completed_at: '2025-01-01T10:04: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'; + process.env.GITHUB_RUN_ATTEMPT = '1'; + + const metrics = await collectMetrics(mockOctokit, mockContext); + + assert.strictEqual(metrics.workflow, 'CI'); + assert.strictEqual(metrics.job.name, 'test-job'); + assert.strictEqual(metrics.job.id, 12345); + assert.strictEqual(metrics.job.conclusion, 'success'); + assert.strictEqual(metrics.job.durationMs, 300000); // 5 minutes + + assert.strictEqual(metrics.steps.length, 2); + assert.strictEqual(metrics.steps[0].name, 'Checkout'); + assert.strictEqual(metrics.steps[0].durationMs, 60000); // 1 minute + assert.strictEqual(metrics.steps[1].name, 'Build'); + assert.strictEqual(metrics.steps[1].durationMs, 180000); // 3 minutes + + assert.strictEqual(metrics.repository.owner, 'test-owner'); + assert.strictEqual(metrics.repository.repo, 'test-repo'); + assert.strictEqual(metrics.run.id, 67890); + }); + + await t.test('should handle missing current job', async () => { + const mockJobData = { + jobs: [ + { + id: 12345, + name: 'other-job', + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:00:00Z', + completed_at: '2025-01-01T10:05:00Z', + steps: [], + }, + ], + }; + + 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 = 'non-existent-job'; + + const metrics = await collectMetrics(mockOctokit, mockContext); + + // Should fallback to first job + assert.strictEqual(metrics.job.name, 'other-job'); + }); + + await t.test('should handle empty steps', async () => { + const mockJobData = { + jobs: [ + { + id: 12345, + name: 'test-job', + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:00:00Z', + completed_at: '2025-01-01T10:05:00Z', + steps: [], + }, + ], + }; + + 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.steps.length, 0); + assert.ok(metrics.job.durationMs > 0); + }); + + await t.test('should handle API errors', async () => { + const mockOctokit = { + rest: { + actions: { + listJobsForWorkflowRun: mock.fn(async () => { + throw new Error('API Error'); + }), + }, + }, + }; + + const mockContext = { + repo: { owner: 'test-owner', repo: 'test-repo' }, + runId: 67890, + runNumber: 42, + workflow: 'CI', + }; + + await assert.rejects( + async () => await collectMetrics(mockOctokit, mockContext), + /API Error/ + ); + }); +}); diff --git a/test/exporter.test.js b/test/exporter.test.js new file mode 100644 index 0000000..2379afe --- /dev/null +++ b/test/exporter.test.js @@ -0,0 +1,235 @@ +const { test, mock } = require('node:test'); +const assert = require('node:assert'); +const { createMeterProvider, recordMetrics } = require('../lib/exporter'); + +test('createMeterProvider', async (t) => { + await t.test('should create MeterProvider with correct configuration', () => { + const config = { + gcpProjectId: 'test-project', + serviceName: 'test-service', + serviceNamespace: 'test-namespace', + metricPrefix: 'test.prefix', + exportIntervalMillis: 5000, + }; + + process.env.GITHUB_RUN_ID = '12345'; + + const { meterProvider, meter } = createMeterProvider(config); + + assert.ok(meterProvider, 'MeterProvider should be created'); + assert.ok(meter, 'Meter should be created'); + }); + + await t.test('should create MeterProvider with service account key', () => { + const serviceAccountKey = { + type: 'service_account', + project_id: 'test-project', + private_key_id: 'key-id', + private_key: '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n', + client_email: 'test@test-project.iam.gserviceaccount.com', + client_id: '12345', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + }; + + const config = { + gcpProjectId: 'test-project', + gcpServiceAccountKey: JSON.stringify(serviceAccountKey), + serviceName: 'test-service', + serviceNamespace: 'test-namespace', + metricPrefix: 'test.prefix', + exportIntervalMillis: 5000, + }; + + process.env.GITHUB_RUN_ID = '12345'; + + const { meterProvider, meter } = createMeterProvider(config); + + assert.ok(meterProvider, 'MeterProvider should be created'); + assert.ok(meter, 'Meter should be created'); + }); + + await t.test('should throw error for invalid service account key JSON', () => { + const config = { + gcpProjectId: 'test-project', + gcpServiceAccountKey: 'invalid-json', + serviceName: 'test-service', + serviceNamespace: 'test-namespace', + metricPrefix: 'test.prefix', + exportIntervalMillis: 5000, + }; + + process.env.GITHUB_RUN_ID = '12345'; + + assert.throws( + () => createMeterProvider(config), + /Invalid service account key JSON/ + ); + }); +}); + +test('recordMetrics', async (t) => { + await t.test('should record step and job metrics', () => { + const mockHistogramRecord = mock.fn(); + const mockCounterAdd = mock.fn(); + + const mockMeter = { + createHistogram: mock.fn((name) => { + return { record: mockHistogramRecord }; + }), + createCounter: mock.fn((name) => { + return { add: mockCounterAdd }; + }), + }; + + const metrics = { + workflow: 'CI', + job: { + name: 'test-job', + id: 12345, + status: 'completed', + conclusion: 'success', + durationMs: 300000, + }, + steps: [ + { + name: 'Checkout', + number: 1, + status: 'completed', + conclusion: 'success', + durationMs: 60000, + }, + { + name: 'Build', + number: 2, + status: 'completed', + conclusion: 'success', + durationMs: 180000, + }, + ], + repository: { + owner: 'test-owner', + repo: 'test-repo', + fullName: 'test-owner/test-repo', + }, + run: { + id: 67890, + number: 42, + attempt: '1', + }, + }; + + recordMetrics(mockMeter, metrics, 'test.prefix'); + + // 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 = { + workflow: 'CI', + job: { + name: 'test-job', + id: 12345, + status: 'in_progress', + conclusion: null, + durationMs: 0, + }, + steps: [ + { + name: 'Pending', + number: 1, + status: 'queued', + conclusion: null, + durationMs: 0, + }, + ], + repository: { + owner: 'test-owner', + repo: 'test-repo', + fullName: 'test-owner/test-repo', + }, + run: { + id: 67890, + number: 42, + attempt: '1', + }, + }; + + 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); + }); + + 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 = { + workflow: 'CI', + job: { + name: 'test-job', + id: 12345, + status: 'completed', + conclusion: 'success', + durationMs: 300000, + }, + steps: [ + { + name: 'Checkout', + number: 1, + status: 'completed', + conclusion: 'success', + durationMs: 60000, + }, + ], + repository: { + owner: 'test-owner', + repo: 'test-repo', + fullName: 'test-owner/test-repo', + }, + run: { + id: 67890, + number: 42, + attempt: '1', + }, + }; + + recordMetrics(mockMeter, metrics, 'test.prefix'); + + // Check that histogram was called with attributes + const histogramCall = mockHistogramRecord.mock.calls[0]; + assert.ok(histogramCall, 'Histogram should be called'); + assert.strictEqual(histogramCall.arguments[0], 300000); // Duration + assert.ok(histogramCall.arguments[1], 'Attributes should be provided'); + assert.strictEqual(histogramCall.arguments[1]['workflow.name'], 'CI'); + assert.strictEqual(histogramCall.arguments[1]['job.name'], 'test-job'); + assert.strictEqual(histogramCall.arguments[1]['repository.owner'], 'test-owner'); + }); +});