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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
@@ -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."
Original file line number Diff line number Diff line change
@@ -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"
62 changes: 62 additions & 0 deletions codebundles/vercel-project-path-traffic-health/README.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions codebundles/vercel-project-path-traffic-health/_vercel_common.sh
Original file line number Diff line number Diff line change
@@ -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"
}
Loading