From 797ce84f3eb380cffb9f24de3f3f982a5a377436 Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Tue, 14 Apr 2026 10:49:08 +0000 Subject: [PATCH] Add vercel-project-path-traffic-health CodeBundle Implements runtime log sampling for Vercel projects: validate API access, resolve production deployment, rank popular and 404 paths, detect 404 share spikes, and summarize by path prefix. Adds sli.robot with bounded sampling, generation rules for vercel_project, SLX/taskset/SLI templates, and minimal .test scaffolding. Relates to runwhen-contrib codecollection-registry#81. Made-with: Cursor --- .../vercel-project-path-traffic-health.yaml | 22 ++ ...ercel-project-path-traffic-health-sli.yaml | 54 ++++ ...ercel-project-path-traffic-health-slx.yaml | 29 ++ ...l-project-path-traffic-health-taskset.yaml | 49 +++ .../.test/Taskfile.yaml | 49 +++ .../.test/validate_bundle.sh | 5 + .../README.md | 62 ++++ .../_vercel_common.sh | 114 +++++++ .../runbook.robot | 292 ++++++++++++++++++ .../sli.robot | 98 ++++++ .../vercel-detect-404-spike.sh | 109 +++++++ .../vercel-resolve-deployment.sh | 69 +++++ .../vercel-sli-score.sh | 84 +++++ .../vercel-summarize-path-prefixes.sh | 101 ++++++ .../vercel-top-paths-404.sh | 91 ++++++ .../vercel-top-paths-popular.sh | 100 ++++++ .../vercel-validate-project.sh | 63 ++++ 17 files changed, 1391 insertions(+) create mode 100644 codebundles/vercel-project-path-traffic-health/.runwhen/generation-rules/vercel-project-path-traffic-health.yaml create mode 100644 codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-sli.yaml create mode 100644 codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-slx.yaml create mode 100644 codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-taskset.yaml create mode 100644 codebundles/vercel-project-path-traffic-health/.test/Taskfile.yaml create mode 100755 codebundles/vercel-project-path-traffic-health/.test/validate_bundle.sh create mode 100644 codebundles/vercel-project-path-traffic-health/README.md create mode 100755 codebundles/vercel-project-path-traffic-health/_vercel_common.sh create mode 100644 codebundles/vercel-project-path-traffic-health/runbook.robot create mode 100644 codebundles/vercel-project-path-traffic-health/sli.robot create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-detect-404-spike.sh create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-resolve-deployment.sh create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-sli-score.sh create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-summarize-path-prefixes.sh create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-top-paths-404.sh create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-top-paths-popular.sh create mode 100755 codebundles/vercel-project-path-traffic-health/vercel-validate-project.sh diff --git a/codebundles/vercel-project-path-traffic-health/.runwhen/generation-rules/vercel-project-path-traffic-health.yaml b/codebundles/vercel-project-path-traffic-health/.runwhen/generation-rules/vercel-project-path-traffic-health.yaml new file mode 100644 index 00000000..e006f054 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/.runwhen/generation-rules/vercel-project-path-traffic-health.yaml @@ -0,0 +1,22 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: vercel + generationRules: + - resourceTypes: + - vercel_project + matchRules: + - type: pattern + pattern: ".+" + properties: ["name"] + mode: substring + slxs: + - baseName: vercel-path-traffic + qualifiers: ["team", "project"] + baseTemplateName: vercel-project-path-traffic-health + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + - type: runbook + templateName: vercel-project-path-traffic-health-taskset.yaml diff --git a/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-sli.yaml b/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-sli.yaml new file mode 100644 index 00000000..6c0e588a --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-sli.yaml @@ -0,0 +1,54 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelIndicator +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + displayUnitsLong: Health Score + displayUnitsShort: score + locations: + - {{default_location}} + description: Scores Vercel project path traffic health from API access, production deployment, and sampled 404 share. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/vercel-project-path-traffic-health/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 180 + configProvided: + - name: VERCEL_TEAM_ID + value: "{{ match_resource.resource.team_id | default(custom.vercel_team_id) }}" + - name: VERCEL_PROJECT + value: "{{ match_resource.resource.project_id | default(match_resource.resource.name) }}" + - name: LOOKBACK_MINUTES + value: "{{ custom.lookback_minutes | default('60') }}" + - name: NOT_FOUND_SPIKE_THRESHOLD_PCT + value: "{{ custom.not_found_spike_threshold_pct | default('15') }}" + - name: SPIKE_MIN_SAMPLE + value: "{{ custom.spike_min_sample | default('40') }}" + - name: LOG_SAMPLE_MAX_LINES + value: "{{ custom.sli_log_sample_max_lines | default('3000') }}" + - name: LOG_FETCH_MAX_SECONDS + value: "{{ custom.sli_log_fetch_max_seconds | default('20') }}" + secretsProvided: + {% if wb_version %} + {% include "vercel-auth.yaml" ignore missing %} + {% else %} + - name: vercel_api_token + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m diff --git a/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-slx.yaml b/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-slx.yaml new file mode 100644 index 00000000..99d3b3a0 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-slx.yaml @@ -0,0 +1,29 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelX +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + imageURL: https://assets.vercel.com/image/upload/front/favicon/vercel/180x180.png + alias: Vercel path traffic {{match_resource.resource.name}} + asMeasuredBy: Sampled runtime request logs for popular paths, 404 routes, and 404 share versus threshold. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{workspace.owner_email}} + statement: Vercel project traffic and missing-route signals should stay within configured thresholds. + additionalContext: + qualified_name: "{{ match_resource.qualified_name }}" + tags: + - name: cloud + value: vercel + - name: service + value: vercel_project + - name: scope + value: project + - name: access + value: read-only diff --git a/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-taskset.yaml b/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-taskset.yaml new file mode 100644 index 00000000..7a91490c --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/.runwhen/templates/vercel-project-path-traffic-health-taskset.yaml @@ -0,0 +1,49 @@ +apiVersion: runwhen.com/v1 +kind: Runbook +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + location: {{default_location}} + description: Analyzes Vercel runtime logs for path traffic, top 404s, and abnormal 404 share for a project. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/vercel-project-path-traffic-health/runbook.robot + configProvided: + - name: VERCEL_TEAM_ID + value: "{{ match_resource.resource.team_id | default(custom.vercel_team_id) }}" + - name: VERCEL_PROJECT + value: "{{ match_resource.resource.project_id | default(match_resource.resource.name) }}" + - name: LOOKBACK_MINUTES + value: "{{ custom.lookback_minutes | default('60') }}" + - name: TOP_N_PATHS + value: "{{ custom.top_n_paths | default('25') }}" + - name: NOT_FOUND_SPIKE_THRESHOLD_PCT + value: "{{ custom.not_found_spike_threshold_pct | default('15') }}" + - name: INCLUDE_3XX + value: "{{ custom.include_3xx | default('true') }}" + - name: SPIKE_MIN_SAMPLE + value: "{{ custom.spike_min_sample | default('40') }}" + - name: LOG_SAMPLE_MAX_LINES + value: "{{ custom.log_sample_max_lines | default('50000') }}" + - name: LOG_FETCH_MAX_SECONDS + value: "{{ custom.log_fetch_max_seconds | default('90') }}" + secretsProvided: + {% if wb_version %} + {% include "vercel-auth.yaml" ignore missing %} + {% else %} + - name: vercel_api_token + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/vercel-project-path-traffic-health/.test/Taskfile.yaml b/codebundles/vercel-project-path-traffic-health/.test/Taskfile.yaml new file mode 100644 index 00000000..3542b83f --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/.test/Taskfile.yaml @@ -0,0 +1,49 @@ +version: "3" + +tasks: + default: + desc: "Run local validation hooks for the CodeBundle" + cmds: + - task: validate-bundle-structure + + clean: + desc: "Cleanup (no local infra for Vercel)" + cmds: + - echo "Nothing to clean." + + build-infra: + desc: "No-op — Vercel tests require live API credentials" + cmds: + - echo "Skipped: provision a Vercel project under VERCEL_TEAM_ID for integration tests." + + check-unpushed-commits: + desc: Check for uncommitted changes in the CodeBundle (skipped if not a git repo) + vars: + BASE_DIR: "../" + cmds: + - | + if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "Not a git repository; skipping uncommitted check." + exit 0 + fi + UNCOMMITTED=$(git diff --name-only HEAD | grep -E "^${BASE_DIR}" | grep -v "/\.test/" || true) + if [ -n "$UNCOMMITTED" ]; then + echo "Uncommitted changes found under ${BASE_DIR}. Commit before release testing." + exit 1 + fi + silent: true + + validate-bundle-structure: + desc: Run lightweight bundle checks + cmds: + - bash validate_bundle.sh + + generate-rwl-config: + desc: Stub for RunWhen Local workflow compatibility + cmds: + - echo "Optional: generate workspaceInfo.yaml when testing with RunWhen Local." + + run-rwl-discovery: + desc: Stub for RunWhen Local workflow compatibility + cmds: + - echo "Optional: run discovery when a workspace is configured." diff --git a/codebundles/vercel-project-path-traffic-health/.test/validate_bundle.sh b/codebundles/vercel-project-path-traffic-health/.test/validate_bundle.sh new file mode 100755 index 00000000..b10fa8fd --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/.test/validate_bundle.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +# Placeholder validation for the Vercel CodeBundle (no local cluster). +# Integration testing uses real Vercel API credentials and a team-scoped project. +echo "ok: vercel-project-path-traffic-health bundle layout" diff --git a/codebundles/vercel-project-path-traffic-health/README.md b/codebundles/vercel-project-path-traffic-health/README.md new file mode 100644 index 00000000..643fc881 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/README.md @@ -0,0 +1,62 @@ +# Vercel Project Path Traffic and Missing Routes + +This CodeBundle reads Vercel **runtime request logs** for a production deployment and summarizes what users hit most often (2xx/optional 3xx), which paths return the most 404s, whether the share of 404s exceeds a threshold, and a first-segment path rollup. It complements synthetic checks by using real request data. Very high traffic may require tighter `LOG_SAMPLE_MAX_LINES` / shorter `LOG_FETCH_MAX_SECONDS`; edge-cached static responses may be under-represented compared to CDN or analytics. + +## Overview + +- **Access validation**: Confirms the token can read the team-scoped project. +- **Deployment selection**: Uses the latest READY **production** deployment for log streaming. +- **Popular paths**: Ranks paths by successful responses in the lookback window. +- **Top 404 paths**: Surfaces the most frequent missing routes. +- **404 spike detection**: Flags when 404s exceed a configured share of sampled requests (minimum sample size required). +- **Prefix summary**: Aggregates total requests and 404 counts by first URL segment (for example `/blog`, `/docs`). +- **SLI**: Lightweight periodic score (0 to 1) from project access, deployment presence, and sampled 404 share. + +## Configuration + +### Required variables + +- `VERCEL_TEAM_ID`: Vercel team id (`teamId`) used on API calls. +- `VERCEL_PROJECT`: Project id or slug to analyze. + +### Optional variables + +- `LOOKBACK_MINUTES`: Log aggregation window in minutes (default: `60`). +- `TOP_N_PATHS`: Number of rows in ranked lists (default: `25`). +- `NOT_FOUND_SPIKE_THRESHOLD_PCT`: Issue when sampled 404 share exceeds this percent (default: `15`). +- `INCLUDE_3XX`: Include redirects in “popular paths” when `true` (default: `true`). +- `SPIKE_MIN_SAMPLE`: Minimum sampled requests with status before evaluating the spike threshold (default: `40`). +- `LOG_SAMPLE_MAX_LINES`: Max streamed log lines processed per runbook task (default: `50000`). +- `LOG_FETCH_MAX_SECONDS`: Max time to read the runtime log stream per task (default: `90`). + +The SLI uses the same variables where applicable; defaults for `LOG_SAMPLE_MAX_LINES` and `LOG_FETCH_MAX_SECONDS` in `sli.robot` are smaller (`3000` / `20`) so the check stays within about 30 seconds. + +### Secrets + +- `vercel_api_token`: Vercel bearer token with read access to projects and deployment runtime logs (plain text). + +## Tasks overview + +### Validate Vercel API Access and Resolve Project + +Checks the token and resolves the project record; raises issues on auth or resolution failures. + +### Resolve Production Deployment for Log Analysis + +Selects the latest READY production deployment used when querying runtime logs. + +### Rank Top Popular Paths by Successful Responses + +Lists the most common paths for 2xx responses (and 3xx when enabled) within the lookback window. + +### Rank Top Missing Paths by 404 Count + +Lists paths with the highest 404 frequency in the sampled logs. + +### Detect Abnormal 404 Spike + +Opens an issue when the 404 share of sampled requests exceeds `NOT_FOUND_SPIKE_THRESHOLD_PCT` and the sample size is at least `SPIKE_MIN_SAMPLE`. + +### Optional Path Prefix Summary + +Rolls up request totals and 404 counts by the first path segment to spot section-level trends. diff --git a/codebundles/vercel-project-path-traffic-health/_vercel_common.sh b/codebundles/vercel-project-path-traffic-health/_vercel_common.sh new file mode 100755 index 00000000..9da3b166 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/_vercel_common.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Shared helpers for Vercel REST API (runtime logs, project resolution). +VERCEL_API="${VERCEL_API:-https://api.vercel.com}" + +vercel_resolve_token() { + if [ -n "${VERCEL_TOKEN:-}" ]; then + return 0 + fi + if [ -n "${vercel_api_token:-}" ] && [ -f "${vercel_api_token}" ]; then + VERCEL_TOKEN="$(cat "${vercel_api_token}")" + export VERCEL_TOKEN + return 0 + fi + return 1 +} + +vercel_quote_path() { + python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1" +} + +# GET ${VERCEL_API}${path} -> writes body to $2, prints HTTP status code to stdout. +vercel_curl_get_body() { + local path="$1" + local out="$2" + local err="$3" + local code + code="$( + curl -sS -o "$out" -w "%{http_code}" \ + -H "Authorization: Bearer ${VERCEL_TOKEN}" \ + "${VERCEL_API}${path}" 2>"$err" + )" || true + echo "${code:-000}" +} + +# Sets VERCEL_PROJECT_ID from VERCEL_PROJECT + VERCEL_TEAM_ID. Returns 0 on success. +vercel_resolve_project_id() { + local enc_p enc_team + enc_p=$(vercel_quote_path "${VERCEL_PROJECT}") + enc_team=$(vercel_quote_path "${VERCEL_TEAM_ID}") + local path="/v9/projects/${enc_p}?teamId=${enc_team}" + local body rc + body="$(mktemp)" + local cerr + cerr="$(mktemp)" + rc="$(vercel_curl_get_body "$path" "$body" "$cerr")" + if [ "$rc" != "200" ]; then + cat "$body" + rm -f "$body" "$cerr" + return 1 + fi + VERCEL_PROJECT_ID="$(jq -r '.id // empty' "$body")" + rm -f "$body" "$cerr" + if [ -z "$VERCEL_PROJECT_ID" ] || [ "$VERCEL_PROJECT_ID" = "null" ]; then + return 1 + fi + export VERCEL_PROJECT_ID + return 0 +} + +# Sets VERCEL_DEPLOYMENT_ID to latest READY production deployment. +vercel_resolve_production_deployment() { + local enc_team enc_proj + enc_team=$(vercel_quote_path "${VERCEL_TEAM_ID}") + enc_proj=$(vercel_quote_path "${VERCEL_PROJECT_ID}") + local path="/v6/deployments?teamId=${enc_team}&projectId=${enc_proj}&target=production&state=READY&limit=1" + local body rc + body="$(mktemp)" + local cerr + cerr="$(mktemp)" + rc="$(vercel_curl_get_body "$path" "$body" "$cerr")" + if [ "$rc" != "200" ]; then + cat "$body" + rm -f "$body" "$cerr" + return 1 + fi + VERCEL_DEPLOYMENT_ID="$(jq -r '.deployments[0].uid // .deployments[0].id // empty' "$body")" + rm -f "$body" "$cerr" + if [ -z "$VERCEL_DEPLOYMENT_ID" ] || [ "$VERCEL_DEPLOYMENT_ID" = "null" ]; then + return 1 + fi + export VERCEL_DEPLOYMENT_ID + return 0 +} + +# Fetch runtime logs into file (bounded). Uses project id + deployment id. +vercel_fetch_runtime_logs_file() { + local out_file="$1" + local max_lines="${LOG_SAMPLE_MAX_LINES:-50000}" + local max_time="${LOG_FETCH_MAX_SECONDS:-90}" + local enc_team enc_proj enc_dep + enc_team=$(vercel_quote_path "${VERCEL_TEAM_ID}") + enc_proj=$(vercel_quote_path "${VERCEL_PROJECT_ID}") + enc_dep=$(vercel_quote_path "${VERCEL_DEPLOYMENT_ID}") + local path="/v1/projects/${enc_proj}/deployments/${enc_dep}/runtime-logs?teamId=${enc_team}" + : >"$out_file" + curl -sS --max-time "$max_time" \ + -H "Authorization: Bearer ${VERCEL_TOKEN}" \ + "${VERCEL_API}${path}" 2>/dev/null | head -n "$max_lines" >"$out_file" || true +} + +# Normalize stream lines to one JSON object per line (strip SSE data: prefix). +vercel_normalize_ndjson() { + local raw="$1" + local norm="$2" + : >"$norm" + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + [ -z "$line" ] && continue + if [[ "$line" == data:* ]]; then + line="${line#data: }" + fi + echo "$line" >>"$norm" + done <"$raw" +} diff --git a/codebundles/vercel-project-path-traffic-health/runbook.robot b/codebundles/vercel-project-path-traffic-health/runbook.robot new file mode 100644 index 00000000..a22c25de --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/runbook.robot @@ -0,0 +1,292 @@ +*** Settings *** +Documentation Analyzes Vercel runtime request logs for popular paths, top 404 routes, and abnormal missing-route spikes for a project. +Metadata Author rw-codebundle-agent +Metadata Display Name Vercel Project Path Traffic and Missing Routes +Metadata Supports Vercel vercel_project traffic observability +Force Tags Vercel vercel_project traffic observability + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + + +*** Tasks *** +Validate Vercel API Access and Resolve Project for `${VERCEL_PROJECT}` + [Documentation] Confirms the Vercel token can access the team and that the project id or slug resolves through the REST API. + [Tags] Vercel vercel_project access:read-only data:config + ${result}= RW.CLI.Run Bash File + ... bash_file=vercel-validate-project.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./vercel-validate-project.sh + ${issues_raw}= RW.CLI.Run Cli + ... cmd=cat vercel_validate_issues.json + ... env=${env} + TRY + ${issue_list}= Evaluate json.loads(r'''${issues_raw.stdout}''') json + EXCEPT + Log Failed to parse JSON for validate task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Vercel API accepts the token and resolves the configured project + ... actual=Project validation failed for `${VERCEL_PROJECT}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Vercel project validation:\n${result.stdout} + +Resolve Production Deployment for Log Analysis for Project `${VERCEL_PROJECT}` + [Documentation] Selects the latest READY production deployment used as the scope for runtime log sampling. + [Tags] Vercel vercel_project deployment access:read-only data:config + ${result}= RW.CLI.Run Bash File + ... bash_file=vercel-resolve-deployment.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=false + ... cmd_override=./vercel-resolve-deployment.sh + ${issues_raw}= RW.CLI.Run Cli + ... cmd=cat vercel_resolve_issues.json + ... env=${env} + TRY + ${issue_list}= Evaluate json.loads(r'''${issues_raw.stdout}''') json + EXCEPT + Log Failed to parse JSON for deployment task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=A production deployment should exist for log analysis + ... actual=Deployment resolution reported a problem for `${VERCEL_PROJECT}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Production deployment resolution:\n${result.stdout} + +Rank Top Popular Paths by Successful Responses for Project `${VERCEL_PROJECT}` + [Documentation] Aggregates runtime logs to list the most visited paths with 2xx responses (and 3xx when enabled) within the lookback window. + [Tags] Vercel vercel_project traffic access:read-only data:metrics + ${result}= RW.CLI.Run Bash File + ... bash_file=vercel-top-paths-popular.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=false + ... cmd_override=LOOKBACK_MINUTES=${LOOKBACK_MINUTES} TOP_N_PATHS=${TOP_N_PATHS} ./vercel-top-paths-popular.sh + ${issues_raw}= RW.CLI.Run Cli + ... cmd=cat vercel_popular_issues.json + ... env=${env} + TRY + ${issue_list}= Evaluate json.loads(r'''${issues_raw.stdout}''') json + EXCEPT + Log Failed to parse JSON for popular paths task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Popular paths analysis should complete without API errors + ... actual=Popular paths task reported an issue for `${VERCEL_PROJECT}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Popular paths:\n${result.stdout} + +Rank Top Missing Paths by 404 Count for Project `${VERCEL_PROJECT}` + [Documentation] Lists request paths with the highest 404 frequency to surface broken links, stale URLs, or rewrite gaps. + [Tags] Vercel vercel_project http-404 access:read-only data:metrics + ${result}= RW.CLI.Run Bash File + ... bash_file=vercel-top-paths-404.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=false + ... cmd_override=LOOKBACK_MINUTES=${LOOKBACK_MINUTES} TOP_N_PATHS=${TOP_N_PATHS} ./vercel-top-paths-404.sh + ${issues_raw}= RW.CLI.Run Cli + ... cmd=cat vercel_404_rank_issues.json + ... env=${env} + TRY + ${issue_list}= Evaluate json.loads(r'''${issues_raw.stdout}''') json + EXCEPT + Log Failed to parse JSON for 404 ranking task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=404 ranking should complete without API errors + ... actual=404 ranking reported an issue for `${VERCEL_PROJECT}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Top 404 paths:\n${result.stdout} + +Detect Abnormal 404 Spike for Project `${VERCEL_PROJECT}` + [Documentation] Compares sampled 404 share to NOT_FOUND_SPIKE_THRESHOLD_PCT when enough requests are present to flag surges in missing-route traffic. + [Tags] Vercel vercel_project http-404 anomaly access:read-only data:metrics + ${result}= RW.CLI.Run Bash File + ... bash_file=vercel-detect-404-spike.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=false + ... cmd_override=NOT_FOUND_SPIKE_THRESHOLD_PCT=${NOT_FOUND_SPIKE_THRESHOLD_PCT} ./vercel-detect-404-spike.sh + ${issues_raw}= RW.CLI.Run Cli + ... cmd=cat vercel_spike_issues.json + ... env=${env} + TRY + ${issue_list}= Evaluate json.loads(r'''${issues_raw.stdout}''') json + EXCEPT + Log Failed to parse JSON for spike task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=404 share should stay below the configured threshold under normal traffic + ... actual=404 share exceeded the threshold for `${VERCEL_PROJECT}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report 404 spike analysis:\n${result.stdout} + +Optional Path Prefix Summary for Project `${VERCEL_PROJECT}` + [Documentation] Rolls up total requests and 404 counts by first URL segment for coarse-grained trends across large sites. + [Tags] Vercel vercel_project traffic access:read-only data:metrics + ${result}= RW.CLI.Run Bash File + ... bash_file=vercel-summarize-path-prefixes.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=false + ... cmd_override=./vercel-summarize-path-prefixes.sh + ${issues_raw}= RW.CLI.Run Cli + ... cmd=cat vercel_prefix_issues.json + ... env=${env} + TRY + ${issue_list}= Evaluate json.loads(r'''${issues_raw.stdout}''') json + EXCEPT + Log Failed to parse JSON for prefix summary task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Prefix summary should complete without API errors + ... actual=Prefix summary reported an issue for `${VERCEL_PROJECT}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Path prefix summary:\n${result.stdout} + + +*** Keywords *** +Suite Initialization + ${vercel_api_token}= RW.Core.Import Secret vercel_api_token + ... type=string + ... description=Vercel bearer token with read access to projects and deployment logs + ... pattern=\w* + ${VERCEL_TEAM_ID}= RW.Core.Import User Variable VERCEL_TEAM_ID + ... type=string + ... description=Vercel team id (teamId) for API scoping + ... pattern=\w* + ${VERCEL_PROJECT}= RW.Core.Import User Variable VERCEL_PROJECT + ... type=string + ... description=Project id or slug to analyze + ... pattern=\w* + ${LOOKBACK_MINUTES}= RW.Core.Import User Variable LOOKBACK_MINUTES + ... type=string + ... description=Log aggregation window in minutes + ... pattern=^\d+$ + ... default=60 + ${TOP_N_PATHS}= RW.Core.Import User Variable TOP_N_PATHS + ... type=string + ... description=How many paths to show in ranked lists + ... pattern=^\d+$ + ... default=25 + ${NOT_FOUND_SPIKE_THRESHOLD_PCT}= RW.Core.Import User Variable NOT_FOUND_SPIKE_THRESHOLD_PCT + ... type=string + ... description=Issue when 404 share of sampled requests exceeds this percent + ... pattern=^\d+(\.\d+)?$ + ... default=15 + ${INCLUDE_3XX}= RW.Core.Import User Variable INCLUDE_3XX + ... type=string + ... description=Include 3xx responses in popular path counts (true/false) + ... pattern=\w* + ... default=true + ${SPIKE_MIN_SAMPLE}= RW.Core.Import User Variable SPIKE_MIN_SAMPLE + ... type=string + ... description=Minimum sampled requests before evaluating 404 spike threshold + ... pattern=^\d+$ + ... default=40 + ${LOG_SAMPLE_MAX_LINES}= RW.Core.Import User Variable LOG_SAMPLE_MAX_LINES + ... type=string + ... description=Maximum streamed log lines to process per task + ... pattern=^\d+$ + ... default=50000 + ${LOG_FETCH_MAX_SECONDS}= RW.Core.Import User Variable LOG_FETCH_MAX_SECONDS + ... type=string + ... description=Maximum time to spend reading the runtime log stream + ... pattern=^\d+$ + ... default=90 + Set Suite Variable ${vercel_api_token} ${vercel_api_token} + Set Suite Variable ${VERCEL_TEAM_ID} ${VERCEL_TEAM_ID} + Set Suite Variable ${VERCEL_PROJECT} ${VERCEL_PROJECT} + Set Suite Variable ${LOOKBACK_MINUTES} ${LOOKBACK_MINUTES} + Set Suite Variable ${TOP_N_PATHS} ${TOP_N_PATHS} + Set Suite Variable ${NOT_FOUND_SPIKE_THRESHOLD_PCT} ${NOT_FOUND_SPIKE_THRESHOLD_PCT} + Set Suite Variable ${INCLUDE_3XX} ${INCLUDE_3XX} + Set Suite Variable ${SPIKE_MIN_SAMPLE} ${SPIKE_MIN_SAMPLE} + Set Suite Variable ${LOG_SAMPLE_MAX_LINES} ${LOG_SAMPLE_MAX_LINES} + Set Suite Variable ${LOG_FETCH_MAX_SECONDS} ${LOG_FETCH_MAX_SECONDS} + ${env}= Create Dictionary + ... VERCEL_TEAM_ID=${VERCEL_TEAM_ID} + ... VERCEL_PROJECT=${VERCEL_PROJECT} + ... LOOKBACK_MINUTES=${LOOKBACK_MINUTES} + ... TOP_N_PATHS=${TOP_N_PATHS} + ... NOT_FOUND_SPIKE_THRESHOLD_PCT=${NOT_FOUND_SPIKE_THRESHOLD_PCT} + ... INCLUDE_3XX=${INCLUDE_3XX} + ... SPIKE_MIN_SAMPLE=${SPIKE_MIN_SAMPLE} + ... LOG_SAMPLE_MAX_LINES=${LOG_SAMPLE_MAX_LINES} + ... LOG_FETCH_MAX_SECONDS=${LOG_FETCH_MAX_SECONDS} + Set Suite Variable ${env} ${env} diff --git a/codebundles/vercel-project-path-traffic-health/sli.robot b/codebundles/vercel-project-path-traffic-health/sli.robot new file mode 100644 index 00000000..8e6b9ae6 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/sli.robot @@ -0,0 +1,98 @@ +*** Settings *** +Documentation Measures Vercel project path-traffic health: API access, production deployment presence, and 404 share versus threshold. Produces a score between 0 (failing) and 1 (healthy). +Metadata Author rw-codebundle-agent +Metadata Display Name Vercel Project Path Traffic and Missing Routes +Metadata Supports Vercel vercel_project traffic observability + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library OperatingSystem + +Suite Setup Suite Initialization + + +*** Tasks *** +Score Vercel Path Traffic Health for Project `${VERCEL_PROJECT}` + [Documentation] Runs a bounded runtime log sample to compute a 0-1 health score from project access, deployment availability, and 404 share. + [Tags] Vercel vercel_project access:read-only data:metrics + ${out}= RW.CLI.Run Bash File + ... bash_file=vercel-sli-score.sh + ... env=${env} + ... secret__vercel_api_token=${vercel_api_token} + ... timeout_seconds=30 + ... include_in_history=false + TRY + ${m}= Evaluate json.loads(r'''${out.stdout}''') json + EXCEPT + Log SLI JSON parse failed, scoring 0. WARN + ${m}= Create Dictionary health=0 project_ok=0 deployment_ok=0 ratio_ok=0 details=SLI JSON parse error + END + ${pok}= Convert To Number ${m['project_ok']} + ${dok}= Convert To Number ${m['deployment_ok']} + ${rok}= Convert To Number ${m['ratio_ok']} + ${h}= Convert To Number ${m['health']} + RW.Core.Push Metric ${pok} sub_name=project_ok + RW.Core.Push Metric ${dok} sub_name=deployment_ok + RW.Core.Push Metric ${rok} sub_name=not_found_ratio_ok + RW.Core.Add to Report Vercel path traffic SLI: ${m['details']} + RW.Core.Push Metric ${h} + + +*** Keywords *** +Suite Initialization + ${vercel_api_token}= RW.Core.Import Secret vercel_api_token + ... type=string + ... description=Vercel bearer token with read access to projects and deployment logs + ... pattern=\w* + ${VERCEL_TEAM_ID}= RW.Core.Import User Variable VERCEL_TEAM_ID + ... type=string + ... description=Vercel team id (teamId) for API scoping + ... pattern=\w* + ${VERCEL_PROJECT}= RW.Core.Import User Variable VERCEL_PROJECT + ... type=string + ... description=Project id or slug to analyze + ... pattern=\w* + ${LOOKBACK_MINUTES}= RW.Core.Import User Variable LOOKBACK_MINUTES + ... type=string + ... description=Log aggregation window in minutes + ... pattern=^\d+$ + ... default=60 + ${NOT_FOUND_SPIKE_THRESHOLD_PCT}= RW.Core.Import User Variable NOT_FOUND_SPIKE_THRESHOLD_PCT + ... type=string + ... description=404 share threshold (percent) used in the SLI ratio check + ... pattern=^\d+(\.\d+)?$ + ... default=15 + ${SPIKE_MIN_SAMPLE}= RW.Core.Import User Variable SPIKE_MIN_SAMPLE + ... type=string + ... description=Minimum sampled requests before applying the 404 threshold in the SLI + ... pattern=^\d+$ + ... default=40 + ${LOG_SAMPLE_MAX_LINES}= RW.Core.Import User Variable LOG_SAMPLE_MAX_LINES + ... type=string + ... description=Maximum streamed log lines for the SLI sample + ... pattern=^\d+$ + ... default=3000 + ${LOG_FETCH_MAX_SECONDS}= RW.Core.Import User Variable LOG_FETCH_MAX_SECONDS + ... type=string + ... description=Maximum seconds to read runtime logs in the SLI + ... pattern=^\d+$ + ... default=20 + Set Suite Variable ${vercel_api_token} ${vercel_api_token} + Set Suite Variable ${VERCEL_TEAM_ID} ${VERCEL_TEAM_ID} + Set Suite Variable ${VERCEL_PROJECT} ${VERCEL_PROJECT} + Set Suite Variable ${LOOKBACK_MINUTES} ${LOOKBACK_MINUTES} + Set Suite Variable ${NOT_FOUND_SPIKE_THRESHOLD_PCT} ${NOT_FOUND_SPIKE_THRESHOLD_PCT} + Set Suite Variable ${SPIKE_MIN_SAMPLE} ${SPIKE_MIN_SAMPLE} + Set Suite Variable ${LOG_SAMPLE_MAX_LINES} ${LOG_SAMPLE_MAX_LINES} + Set Suite Variable ${LOG_FETCH_MAX_SECONDS} ${LOG_FETCH_MAX_SECONDS} + ${env}= Create Dictionary + ... VERCEL_TEAM_ID=${VERCEL_TEAM_ID} + ... VERCEL_PROJECT=${VERCEL_PROJECT} + ... LOOKBACK_MINUTES=${LOOKBACK_MINUTES} + ... NOT_FOUND_SPIKE_THRESHOLD_PCT=${NOT_FOUND_SPIKE_THRESHOLD_PCT} + ... SPIKE_MIN_SAMPLE=${SPIKE_MIN_SAMPLE} + ... LOG_SAMPLE_MAX_LINES=${LOG_SAMPLE_MAX_LINES} + ... LOG_FETCH_MAX_SECONDS=${LOG_FETCH_MAX_SECONDS} + Set Suite Variable ${env} ${env} diff --git a/codebundles/vercel-project-path-traffic-health/vercel-detect-404-spike.sh b/codebundles/vercel-project-path-traffic-health/vercel-detect-404-spike.sh new file mode 100755 index 00000000..81b75227 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-detect-404-spike.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Flags when 404 responses exceed a share of sampled request logs in the window. +# Writes vercel_spike_issues.json +# ----------------------------------------------------------------------------- + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +LOOKBACK_MINUTES="${LOOKBACK_MINUTES:-60}" +NOT_FOUND_SPIKE_THRESHOLD_PCT="${NOT_FOUND_SPIKE_THRESHOLD_PCT:-15}" +SPIKE_MIN_SAMPLE="${SPIKE_MIN_SAMPLE:-40}" +LOG_SAMPLE_MAX_LINES="${LOG_SAMPLE_MAX_LINES:-50000}" +LOG_FETCH_MAX_SECONDS="${LOG_FETCH_MAX_SECONDS:-90}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +OUTPUT_ISSUES="vercel_spike_issues.json" +issues_json='[]' + +if ! vercel_resolve_token; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Vercel API token missing for 404 spike detection" \ + --arg details "Missing credentials." \ + --argjson severity 4 \ + --arg next_steps "Configure vercel_api_token." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_project_id 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot resolve project for 404 spike detection" \ + --arg details "Project resolution failed." \ + --argjson severity 3 \ + --arg next_steps "Check VERCEL_PROJECT and team id." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_production_deployment 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No production deployment for 404 spike detection" \ + --arg details "Could not select a deployment." \ + --argjson severity 3 \ + --arg next_steps "Deploy to production first." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +raw="$(mktemp)" +norm="$(mktemp)" +vercel_fetch_runtime_logs_file "$raw" +vercel_normalize_ndjson "$raw" "$norm" +rm -f "$raw" + +now_ms="$(python3 -c "import time; print(int(time.time()*1000))")" +cutoff_ms=$((now_ms - LOOKBACK_MINUTES * 60 * 1000)) + +stats="$(jq -s --argjson cutoff_ms "$cutoff_ms" \ + 'map(select(.source != "delimiter")) + | map(select(.timestampInMs >= $cutoff_ms)) + | map(select(.requestPath != null and .requestPath != "")) + | map(select(.responseStatusCode != null)) + | . as $rows + | ($rows | length) as $total + | ($rows | map(select(.responseStatusCode == 404)) | length) as $n404 + | { + total: $total, + not_found: $n404, + pct: (if $total > 0 then ($n404 * 100.0 / $total) else 0 end) + }' "$norm")" + +total="$(echo "$stats" | jq -r '.total')" +n404="$(echo "$stats" | jq -r '.not_found')" +pct="$(echo "$stats" | jq -r '.pct')" + +{ + echo "404 share analysis (last ${LOOKBACK_MINUTES} minutes, sampled logs)" + echo "Sample lines cap: ${LOG_SAMPLE_MAX_LINES}; min requests for alert: ${SPIKE_MIN_SAMPLE}" + echo "Total sampled requests with status: ${total}" + echo "404 count: ${n404}" + echo "404 share (%): $(printf '%.2f\n' "$pct")" + echo "Threshold (%): ${NOT_FOUND_SPIKE_THRESHOLD_PCT}" +} + +if [ "${total:-0}" -ge "${SPIKE_MIN_SAMPLE}" ] 2>/dev/null; then + if echo "$stats" | jq -e --argjson th "${NOT_FOUND_SPIKE_THRESHOLD_PCT}" '.pct > $th' >/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Elevated 404 share for project \`${VERCEL_PROJECT}\`" \ + --arg details "404s are ${pct}% of ${total} sampled requests (threshold ${NOT_FOUND_SPIKE_THRESHOLD_PCT}%). Missing-route traffic may have spiked." \ + --argjson severity 3 \ + --arg next_steps "Review top 404 paths task, check rewrites and SEO links, and validate recent deployments." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + fi +else + echo "Not enough samples (${total}) to evaluate spike threshold (need >= ${SPIKE_MIN_SAMPLE})." +fi + +echo "$issues_json" >"$OUTPUT_ISSUES" +rm -f "$norm" +exit 0 diff --git a/codebundles/vercel-project-path-traffic-health/vercel-resolve-deployment.sh b/codebundles/vercel-project-path-traffic-health/vercel-resolve-deployment.sh new file mode 100755 index 00000000..759aebd6 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-resolve-deployment.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Resolves latest READY production deployment for log analysis. +# Outputs JSON issues to vercel_resolve_issues.json (if any). +# ----------------------------------------------------------------------------- + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +OUTPUT_FILE="vercel_resolve_issues.json" +issues_json='[]' + +if ! vercel_resolve_token; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Vercel API token missing for project \`${VERCEL_PROJECT}\`" \ + --arg details "Cannot resolve deployment without API credentials." \ + --argjson severity 4 \ + --arg next_steps "Configure the vercel_api_token secret." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + echo "$issues_json" >"$OUTPUT_FILE" + exit 0 +fi + +if ! err_body="$(vercel_resolve_project_id 2>&1)"; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot resolve project before deployment lookup for \`${VERCEL_PROJECT}\`" \ + --arg details "$err_body" \ + --argjson severity 3 \ + --arg next_steps "Fix VERCEL_PROJECT and VERCEL_TEAM_ID, then retry." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + echo "$issues_json" >"$OUTPUT_FILE" + exit 0 +fi + +if ! dep_err="$(vercel_resolve_production_deployment 2>&1)"; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No production deployment found for project \`${VERCEL_PROJECT}\`" \ + --arg details "$dep_err" \ + --argjson severity 3 \ + --arg next_steps "Deploy to production or verify project has a READY production deployment." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + echo "$issues_json" >"$OUTPUT_FILE" + exit 0 +fi + +echo "Using deployment ${VERCEL_DEPLOYMENT_ID} for project ${VERCEL_PROJECT_ID}" +echo "$issues_json" >"$OUTPUT_FILE" +exit 0 diff --git a/codebundles/vercel-project-path-traffic-health/vercel-sli-score.sh b/codebundles/vercel-project-path-traffic-health/vercel-sli-score.sh new file mode 100755 index 00000000..620b4f31 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-sli-score.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail +# Lightweight SLI: project + deployment checks and bounded 404 share sample. +# Prints a single JSON object on stdout (no trace noise). + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +LOOKBACK_MINUTES="${LOOKBACK_MINUTES:-60}" +NOT_FOUND_SPIKE_THRESHOLD_PCT="${NOT_FOUND_SPIKE_THRESHOLD_PCT:-15}" +SPIKE_MIN_SAMPLE="${SPIKE_MIN_SAMPLE:-40}" +LOG_SAMPLE_MAX_LINES="${LOG_SAMPLE_MAX_LINES:-3000}" +LOG_FETCH_MAX_SECONDS="${LOG_FETCH_MAX_SECONDS:-20}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +proj_ok=0 +dep_ok=0 +ratio_ok=1 + +if ! vercel_resolve_token; then + jq -n --argjson p 0 --argjson d 0 --argjson r 0 \ + '{health: 0, project_ok: 0, deployment_ok: 0, ratio_ok: 0, details: "missing token"}' + exit 0 +fi + +if vercel_resolve_project_id 2>/dev/null; then + proj_ok=1 +fi + +if [ "$proj_ok" -eq 1 ] && vercel_resolve_production_deployment 2>/dev/null; then + dep_ok=1 +fi + +if [ "$proj_ok" -eq 0 ] || [ "$dep_ok" -eq 0 ]; then + h=$(jq -n --argjson p "$proj_ok" --argjson d "$dep_ok" --argjson r 0 \ + '($p + $d) / 2') + jq -n --argjson p "$proj_ok" --argjson d "$dep_ok" --argjson h "$h" \ + --arg det "project or deployment unresolved" \ + '{health: $h, project_ok: $p, deployment_ok: $d, ratio_ok: 0, details: $det}' + exit 0 +fi + +raw="$(mktemp)" +norm="$(mktemp)" +vercel_fetch_runtime_logs_file "$raw" +vercel_normalize_ndjson "$raw" "$norm" +rm -f "$raw" + +now_ms="$(python3 -c "import time; print(int(time.time()*1000))")" +cutoff_ms=$((now_ms - LOOKBACK_MINUTES * 60 * 1000)) + +stats="$(jq -s --argjson cutoff_ms "$cutoff_ms" \ + 'map(select(.source != "delimiter")) + | map(select(.timestampInMs >= $cutoff_ms)) + | map(select(.requestPath != null and .requestPath != "")) + | map(select(.responseStatusCode != null)) + | . as $rows + | ($rows | length) as $total + | ($rows | map(select(.responseStatusCode == 404)) | length) as $n404 + | { + total: $total, + not_found: $n404, + pct: (if $total > 0 then ($n404 * 100.0 / $total) else 0 end) + }' "$norm")" +rm -f "$norm" + +total="$(echo "$stats" | jq -r '.total')" +pct="$(echo "$stats" | jq -r '.pct')" + +ratio_ok=1 +if [ "${total:-0}" -ge "${SPIKE_MIN_SAMPLE}" ] 2>/dev/null; then + if echo "$stats" | jq -e --argjson th "${NOT_FOUND_SPIKE_THRESHOLD_PCT}" '.pct > $th' >/dev/null; then + ratio_ok=0 + fi +fi + +echo "$stats" | jq --argjson p 1 --argjson d 1 --argjson r "$ratio_ok" \ + '{health: ((1 + 1 + $r) / 3), project_ok: $p, deployment_ok: $d, ratio_ok: $r, + details: ("samples=" + (.total|tostring) + " 404_pct=" + (.pct|tostring)) }' + +exit 0 diff --git a/codebundles/vercel-project-path-traffic-health/vercel-summarize-path-prefixes.sh b/codebundles/vercel-project-path-traffic-health/vercel-summarize-path-prefixes.sh new file mode 100755 index 00000000..9dba1499 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-summarize-path-prefixes.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Rolls up request and 404 counts by first path segment (e.g. /blog, /docs). +# Writes vercel_prefix_issues.json (usually empty). +# ----------------------------------------------------------------------------- + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +LOOKBACK_MINUTES="${LOOKBACK_MINUTES:-60}" +TOP_N_PATHS="${TOP_N_PATHS:-25}" +LOG_SAMPLE_MAX_LINES="${LOG_SAMPLE_MAX_LINES:-50000}" +LOG_FETCH_MAX_SECONDS="${LOG_FETCH_MAX_SECONDS:-90}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +OUTPUT_ISSUES="vercel_prefix_issues.json" +issues_json='[]' + +if ! vercel_resolve_token; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Vercel API token missing for path prefix summary" \ + --arg details "Missing credentials." \ + --argjson severity 4 \ + --arg next_steps "Configure vercel_api_token." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_project_id 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot resolve project for path prefix summary" \ + --arg details "Project resolution failed." \ + --argjson severity 3 \ + --arg next_steps "Check VERCEL_PROJECT and team id." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_production_deployment 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No production deployment for path prefix summary" \ + --arg details "Could not select a deployment." \ + --argjson severity 3 \ + --arg next_steps "Deploy to production first." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +raw="$(mktemp)" +norm="$(mktemp)" +vercel_fetch_runtime_logs_file "$raw" +vercel_normalize_ndjson "$raw" "$norm" +rm -f "$raw" + +now_ms="$(python3 -c "import time; print(int(time.time()*1000))")" +cutoff_ms=$((now_ms - LOOKBACK_MINUTES * 60 * 1000)) + +jq -s --argjson cutoff_ms "$cutoff_ms" --argjson topn "$TOP_N_PATHS" \ + 'def prefix: + if . == "/" or . == "" then "(root)" + else + (split("/") | if length > 1 then "/" + .[1] else "/" end) + end; + map(select(.source != "delimiter")) + | map(select(.timestampInMs >= $cutoff_ms)) + | map(select(.requestPath != null and .requestPath != "")) + | map(select(.responseStatusCode != null)) + | map(. + {pfx: (.requestPath | prefix)}) + | group_by(.pfx) + | map({ + prefix: .[0].pfx, + requests: length, + not_found: (map(select(.responseStatusCode == 404)) | length) + }) + | sort_by(-.requests) + | .[:$topn]' "$norm" >prefix_agg.json + +jq -r '.[] | "\(.requests)\t\(.not_found)\t\(.prefix)"' prefix_agg.json >prefix_table.txt || true + +{ + echo "Path prefix rollup (first segment). Window: last ${LOOKBACK_MINUTES} minutes." + echo "Columns: total_requests, not_found_404, prefix" + echo "" + if [ ! -s prefix_table.txt ]; then + echo "No samples in window." + else + column -t prefix_table.txt 2>/dev/null || cat prefix_table.txt + fi +} + +echo "$issues_json" >"$OUTPUT_ISSUES" +rm -f "$norm" prefix_agg.json prefix_table.txt +exit 0 diff --git a/codebundles/vercel-project-path-traffic-health/vercel-top-paths-404.sh b/codebundles/vercel-project-path-traffic-health/vercel-top-paths-404.sh new file mode 100755 index 00000000..a775c7c1 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-top-paths-404.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Ranks paths with highest 404 frequency from runtime request logs. +# Writes vercel_404_rank_issues.json (empty unless API errors). +# ----------------------------------------------------------------------------- + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +LOOKBACK_MINUTES="${LOOKBACK_MINUTES:-60}" +TOP_N_PATHS="${TOP_N_PATHS:-25}" +LOG_SAMPLE_MAX_LINES="${LOG_SAMPLE_MAX_LINES:-50000}" +LOG_FETCH_MAX_SECONDS="${LOG_FETCH_MAX_SECONDS:-90}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +OUTPUT_ISSUES="vercel_404_rank_issues.json" +issues_json='[]' + +if ! vercel_resolve_token; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Vercel API token missing for 404 path ranking" \ + --arg details "Missing credentials." \ + --argjson severity 4 \ + --arg next_steps "Configure vercel_api_token." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_project_id 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot resolve project for 404 path ranking" \ + --arg details "Project resolution failed." \ + --argjson severity 3 \ + --arg next_steps "Check VERCEL_PROJECT and team id." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_production_deployment 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No production deployment for 404 path ranking" \ + --arg details "Could not select a deployment." \ + --argjson severity 3 \ + --arg next_steps "Deploy to production first." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +raw="$(mktemp)" +norm="$(mktemp)" +vercel_fetch_runtime_logs_file "$raw" +vercel_normalize_ndjson "$raw" "$norm" +rm -f "$raw" + +now_ms="$(python3 -c "import time; print(int(time.time()*1000))")" +lb_ms=$((LOOKBACK_MINUTES * 60 * 1000)) +cutoff_ms=$((now_ms - lb_ms)) + +jq -s --argjson cutoff_ms "$cutoff_ms" \ + 'map(select(.source != "delimiter")) + | map(select(.timestampInMs >= $cutoff_ms)) + | map(select(.requestPath != null and .requestPath != "")) + | map(select(.responseStatusCode == 404)) + | group_by(.requestPath) + | map({path: .[0].requestPath, count: length}) + | sort_by(-.count)' "$norm" >agg404.json + +jq --argjson topn "$TOP_N_PATHS" '.[:$topn] | .[] | "\(.count)\t\(.path)"' agg404.json >table404.txt || true + +{ + echo "Top paths by 404 count (last ${LOOKBACK_MINUTES} minutes)" + echo "Sample capped at ${LOG_SAMPLE_MAX_LINES} lines. Methodology: runtime request logs; edge-cached responses may not appear." + echo "" + if [ ! -s table404.txt ]; then + echo "No 404 samples in window (or stream empty)." + else + column -t table404.txt 2>/dev/null || cat table404.txt + fi +} + +echo "$issues_json" >"$OUTPUT_ISSUES" +rm -f "$norm" agg404.json table404.txt +exit 0 diff --git a/codebundles/vercel-project-path-traffic-health/vercel-top-paths-popular.sh b/codebundles/vercel-project-path-traffic-health/vercel-top-paths-popular.sh new file mode 100755 index 00000000..ac0cdaaa --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-top-paths-popular.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Ranks top request paths by successful (2xx / optional 3xx) responses. +# Writes vercel_popular_issues.json (usually empty unless API error). +# ----------------------------------------------------------------------------- + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +LOOKBACK_MINUTES="${LOOKBACK_MINUTES:-60}" +TOP_N_PATHS="${TOP_N_PATHS:-25}" +INCLUDE_3XX="${INCLUDE_3XX:-true}" +LOG_SAMPLE_MAX_LINES="${LOG_SAMPLE_MAX_LINES:-50000}" +LOG_FETCH_MAX_SECONDS="${LOG_FETCH_MAX_SECONDS:-90}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +OUTPUT_ISSUES="vercel_popular_issues.json" +issues_json='[]' + +if ! vercel_resolve_token; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Vercel API token missing for popular paths report" \ + --arg details "Missing credentials." \ + --argjson severity 4 \ + --arg next_steps "Configure vercel_api_token." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_project_id 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot resolve project for popular paths" \ + --arg details "Project resolution failed." \ + --argjson severity 3 \ + --arg next_steps "Check VERCEL_PROJECT and team id." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +if ! vercel_resolve_production_deployment 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No production deployment for popular paths analysis" \ + --arg details "Could not select a deployment." \ + --argjson severity 3 \ + --arg next_steps "Deploy to production first." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" >"$OUTPUT_ISSUES" + exit 0 +fi + +raw="$(mktemp)" +norm="$(mktemp)" +vercel_fetch_runtime_logs_file "$raw" +vercel_normalize_ndjson "$raw" "$norm" +rm -f "$raw" + +now_ms="$(python3 -c "import time; print(int(time.time()*1000))")" +lb_ms=$((LOOKBACK_MINUTES * 60 * 1000)) +cutoff_ms=$((now_ms - lb_ms)) + +inc_3=0 +if [ "$INCLUDE_3XX" = "true" ] || [ "$INCLUDE_3XX" = "1" ]; then + inc_3=1 +fi + +jq -s --argjson cutoff_ms "$cutoff_ms" --argjson inc3 "$inc_3" \ + 'map(select(.source != "delimiter")) + | map(select(.timestampInMs >= $cutoff_ms)) + | map(select(.requestPath != null and .requestPath != "")) + | map(select( + (.responseStatusCode >= 200 and .responseStatusCode < 300) + or ($inc3 == 1 and .responseStatusCode >= 300 and .responseStatusCode < 400) + )) + | group_by(.requestPath) + | map({path: .[0].requestPath, count: length}) + | sort_by(-.count)' "$norm" >popular_agg.json + +jq --argjson topn "$TOP_N_PATHS" '.[:$topn] | .[] | "\(.count)\t\(.path)"' popular_agg.json >popular_table.txt || true + +{ + echo "Top popular paths (2xx$([ "$inc_3" = "1" ] && echo " and 3xx") ) in last ${LOOKBACK_MINUTES} minutes" + echo "Sample capped at ${LOG_SAMPLE_MAX_LINES} lines / ${LOG_FETCH_MAX_SECONDS}s fetch. Static edge-cached hits may be absent from runtime logs." + echo "" + if [ ! -s popular_table.txt ]; then + echo "No matching request samples in window (or stream empty)." + else + column -t popular_table.txt 2>/dev/null || cat popular_table.txt + fi +} + +echo "$issues_json" >"$OUTPUT_ISSUES" +rm -f "$norm" popular_agg.json popular_table.txt +exit 0 diff --git a/codebundles/vercel-project-path-traffic-health/vercel-validate-project.sh b/codebundles/vercel-project-path-traffic-health/vercel-validate-project.sh new file mode 100755 index 00000000..2364eaa6 --- /dev/null +++ b/codebundles/vercel-project-path-traffic-health/vercel-validate-project.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Validates Vercel bearer token and resolves project metadata. +# Outputs JSON issues array to vercel_validate_issues.json +# ----------------------------------------------------------------------------- + +: "${VERCEL_TEAM_ID:?Must set VERCEL_TEAM_ID}" +: "${VERCEL_PROJECT:?Must set VERCEL_PROJECT}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/_vercel_common.sh" + +OUTPUT_FILE="vercel_validate_issues.json" +issues_json='[]' + +if ! vercel_resolve_token; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Vercel API token missing for project \`${VERCEL_PROJECT}\`" \ + --arg details "Set secret vercel_api_token or VERCEL_TOKEN for API access." \ + --argjson severity 4 \ + --arg next_steps "Add a valid Vercel token with read access to the team and project." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + echo "$issues_json" >"$OUTPUT_FILE" + echo "Validation failed: missing token." + exit 0 +fi + +if ! err_body="$(vercel_resolve_project_id 2>&1)"; then + sev=3 + details="$err_body" + if echo "$err_body" | jq -e . >/dev/null 2>&1; then + code=$(echo "$err_body" | jq -r '.error.code // empty') + if [ "$code" = "forbidden" ] || [ "$code" = "unauthorized" ]; then + sev=4 + fi + details=$(echo "$err_body" | jq -c .) + fi + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot resolve Vercel project \`${VERCEL_PROJECT}\`" \ + --arg details "$details" \ + --argjson severity "$sev" \ + --arg next_steps "Verify VERCEL_TEAM_ID, project slug or id, and token scope." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') +else + echo "Resolved project id: ${VERCEL_PROJECT_ID}" +fi + +echo "$issues_json" >"$OUTPUT_FILE" +echo "Wrote $OUTPUT_FILE" +exit 0