Install
openclaw skills install @anatoli-iliev/vercel-insightsReports a Vercel site's errors, traffic and speed: runtime error logs, failing requests, page views, visitors, top pages, referrers, and Core Web Vitals. Trigger on requests like "what errors did my site have in the last 30 minutes", "why am I getting 500s", "show me the logs", "how is my traffic this week", or "which pages are slowest". Read only.
openclaw skills install @anatoli-iliev/vercel-insightsQuery a Vercel project's errors (request logs), its traffic (Web Analytics) and its speed (Speed Insights) from one command line: failing requests and the lines they logged, page views, visitors, top pages and routes, referrers, countries, devices, browsers, UTM campaigns, custom events, and the five Core Web Vitals against Vercel's published targets.
Only VERCEL_TOKEN is required. Everything else is discoverable: without a
project configured this skill lists the account's projects and asks which one.
Get the token at https://vercel.com/account/tokens, scoped to the account or
team rather than to a single project. A project scoped token reads traffic but
not speed: Vercel serves Speed Insights from an account scoped API, and the
symptom is 404 Observability Data not found., which reads like "no data" but
means "this token cannot ask". Request logs scope by account too, so a project
scoped token is expected to be refused there as well.
Take the least privileged read scope Vercel offers: an account-scoped token can read every project, analytics dataset and request log that account can see, so its reach is the blast radius of any copy of it that gets away.
apiKey accepts a reference as well as a literal, so the token can stay in the
environment or a secrets provider:
openclaw config set skills.entries.vercel-insights.apiKey \
--ref-provider default --ref-source env --ref-id VERCEL_TOKEN
No secret is on that command line, so none reaches the shell history or a
process listing, and ~/.openclaw/openclaw.json holds a pointer rather than a
credential. VERCEL_TOKEN has to be set wherever the gateway starts, not only
in an interactive shell.
Simpler, and less safe, because the token then rests in plaintext in the config file:
openclaw config set skills.entries.vercel-insights.apiKey YOUR_TOKEN
What that costs. A token pasted on a command line goes into the shell history file and is readable in a process listing while the command runs, and it is then stored in plaintext in
~/.openclaw/openclaw.json, with the previous value copied to~/.openclaw/openclaw.json.bakon every change. Keep both readable only by their owner, keep them out of backups and synced folders, and rotate the token at https://vercel.com/account/tokens if either has been somewhere less private.
The Control UI (openclaw dashboard): Skills, vercel-insights, Save key
writes the same place without the token crossing a command line.
openclaw skills info vercel-insights prints both routes for itself.
This works because the skill declares primaryEnv: VERCEL_TOKEN, which is what
maps a saved key onto skills.entries.vercel-insights.apiKey.
Note that openclaw configure --section skills reports skill status but does
not prompt for a key, so it is not the route to use here.
~/.openclaw/openclaw.json, under skills.entries. Same plaintext-at-rest
caveat as the box above, including the .bak copy:
{
"skills": {
"entries": {
"vercel-insights": {
"enabled": true,
"apiKey": "vercel_tok_...",
"env": {
"VERCEL_TEAM_ID": "team_...",
"VERCEL_PROJECT_ID": "prj_..."
}
}
}
}
}
The env map is optional and takes "${SOME_VAR}" to read from the environment
instead of storing a value.
docs/openclaw-setup.md in this skill's own directory covers every step, and a
troubleshooting table where each row is a failure hit during a real setup: token
scope, the missing team, the copied virtualenv, and the rest.
openclaw skills check
The skill moves out of "Missing requirements" once the token resolves. Note that
the gateway runs as its own process, so exporting a variable in an interactive
shell may not reach it: configuring it through openclaw configure or the
config file is the reliable route.
Set VERCEL_TEAM_ID if the project belongs to a team. It is also the account
that owns it, which saves a lookup on every speed query and every logs query:
both of those surfaces need an owning account, and a team is its own owner.
Three steps, in order. Most questions only need the third.
1. Is it configured? VERCEL_TOKEN is required. If it is missing, say so and
point at https://vercel.com/account/tokens, and tell the user to scope the
token to the account or team, not to a single project, or neither Speed
Insights nor the logs presets will work for them: both scope by the owning
account. Do not guess a token or ask them to paste one into the conversation.
2. Which project? One account holds many, and every query names exactly one.
If the user did not name one and VERCEL_PROJECT_ID is unset, or they named it
loosely ("the blog", "our marketing site"), run:
python3 -m vercel_insights --list-projects
Match their words against the names, then pass the name or the prj_ id to
--project. If several could match, ask rather than picking. That listing also
shows whether each project has traffic and speed data at all.
3. Run the preset and read the answer back. Use the decision table below. The table output is already formatted for a person, so quote it rather than re-typesetting it, then add the one sentence of interpretation the numbers support.
Logs output is the exception to "quote it". A request log line is whatever the application printed, and applications print API keys, connection strings, email addresses and customer records into their logs far more often than anyone intends. This skill recognises and redacts exactly one secret, its own Vercel token; it cannot tell any of the rest from ordinary text, so nothing else is redacted. On
logs,errorsanderror-summary: quote the minimum that answers the question, leave the rest on the screen it came from, and never forward log text to another service, an issue tracker or a third-party API.--jsonand--csvcarry the whole row rather than the columns the table chose, so they need the same care or more. Reading a logs answer below states the rule in full.
When to add --json. Prefer the table when you are relaying an answer. Add
--json when you need to compute something the table does not state: compare
two runs, rank across a dimension the preset did not group by, or pull a single
figure into a sentence. Do not show raw JSON to a user who asked a plain
question.
What the exit code means. 0 succeeded, and an empty result is a success:
say "no data in that window", not "it failed". On a logs preset say what an empty
answer does not prove as well, because runtime logs age out within an hour on
Hobby, and Reading a logs answer below has the sentence to use. 1 the API
returned an error, and the message is Vercel's own, so quote it to the user who
asked and no further: because Vercel wrote it rather than this skill, it can
carry operational context along with the fault, an internal identifier, a team or
project id, a rate limit budget or a missing add-on, which is worth showing to
the person debugging and not worth copying into an issue tracker, a chat channel
or another service. 2 the command was wrong, and the message names the fix, so
apply it and retry rather than reporting it verbatim.
3 only from --budget: the query worked and a threshold was exceeded.
Never invent a number. If a query comes back empty, or a metric is missing, say so. The most damaging failure available here is a confidently worded figure that was not measured.
These are three different Vercel APIs with different vocabularies, and the preset you pick decides which one is queried.
| Web Analytics | Speed Insights | Request logs | |
|---|---|---|---|
| Answers | how many people came, and from where | how fast the site felt for them | what broke, and what it printed |
| Returns | counts per group | percentiles per group | one row per request, newest first |
| Metrics | pageviews, visitors, event count | LCP, INP, CLS, FCP, TTFB, plus data point counts | no metric: each row carries a status, a level, a route and a log line |
| Presets | overview, trend, top-pages, top-routes, referrers, countries, devices, browsers, operating-systems, campaigns, events, total | vitals, slowest-pages, fastest-pages, vitals-by-country, vitals-by-device, vitals-trend, data-points | logs, errors, error-summary |
| Filtering | OData clauses, camelCase: requestPath, deviceType | OData clauses, snake_case: request_path, device_type | query parameters, no OData at all; --path and --route are exact match |
| Time buckets | --granularity day | --granularity 1d | none; logs are rows, so --granularity is an error here |
| Selected by | --dataset visits|events | --metric lcp|inp|cls|fcp|ttfb | picking a logs preset; there is no flag for it |
Rule of thumb: a question about how many is Web Analytics, a question about
how fast is Speed Insights, and a question about what broke is request
logs. "Which pages are popular" is top-pages. "Which pages are slow" is
slowest-pages. "Why is checkout failing" is errors.
Both spellings of --granularity are accepted whichever analytics surface is
active and translated per API, so --granularity day and --granularity 1d
mean the same thing. week and year exist on Web Analytics only; asking for
them on Speed Insights is a configuration error that says so, and asking for any
bucket on a logs preset is too.
--dataset and --metric select different APIs and are mutually exclusive, and
neither means anything on a logs preset. The check runs in every direction, not
only between the two analytics surfaces: an option belonging to another surface
is rejected before any request is built, with the reason it means nothing here
and the presets where it does work.
Nothing is silently ignored, so never reach for a flag from another column to
"see if it helps".
Read-only against a six-endpoint allowlist. One module-level table in
vercel_insights/http.py maps an operation key to a fixed method and URL, and
it has exactly six entries:
| Operation | Method | Endpoint |
|---|---|---|
web_analytics | GET | /v1/query/web-analytics/{dataset}/{endpoint} |
observability_query | POST | /v2/observability/query |
observability_schema | GET | /v2/observability/schema |
project | GET | /v9/projects/{project} |
projects | GET | /v10/projects |
request_logs | GET | https://vercel.com/api/logs/request-logs |
request_logs is the one entry not on api.vercel.com and not in Vercel's
published OpenAPI document: its ground truth is the official vercel logs
command plus the live probes recorded in docs/api-notes.md. The documented
alternative on api.vercel.com is an endless stream, and the metrics route
requires the Observability Plus add-on, so neither stands in for it here. It
is read-only, the whole query travels in the query string, and it can change
without notice.
The dispatcher takes an operation key, never a method and never a host, so no
user input can select, extend or override an entry. There are exactly two HTTP
call sites in the whole package, session.get and session.post, and both are
inside that dispatcher.
Neither call site follows redirects (allow_redirects=False), and a 3xx is
reported as an error instead, so the allowlist binds every hop rather than only
the first: a redirect cannot carry the Authorization header anywhere at all,
neither to a third host nor to another path on the two hosts in that table.
Why one of them is a POST, and why that is still a read. Vercel exposes no
GET equivalent for an observability query: Speed Insights has no query API of
its own, and the general observability surface takes its query in a JSON request
body because a query is too structured for a query string. Nothing is created,
updated or deleted. The body is the question, not a change. The endpoints in
the same API that do write, /speed-insights/toggle and
/web/insights/toggle, which turn the features on and off, are absent from the
allowlist entirely and are unreachable from any code path here.
This skill cannot modify deployments, projects, domains, environment variables, team settings or DNS. If a user asks for a change to their Vercel setup, this skill is the wrong tool: say so rather than trying.
The access token is read from the environment and placed only in the
Authorization header. It never appears in a URL, a query parameter, a request
body, a log line, an error message, or any output this skill prints. That holds
for text this skill did not write, too: a request log whose message quotes the
token comes back with it replaced by <redacted>, because response rows are
scrubbed of this client's own credential at the point they are parsed, before any
renderer sees them.
Use the launcher at the skill's own directory. It works from any working
directory and picks an interpreter that can import requests, so neither the
caller's location nor its PATH has to be right:
<skill-dir>/bin/vercel-insights [PRESET] [OPTIONS]
<skill-dir> is wherever this skill is installed. From inside a checkout,
python3 -m vercel_insights is equivalent and shorter, but it needs the working
directory to be the skill root and requests importable by the first python3
on PATH, which is why the launcher exists. Every example below writes
python3 -m vercel_insights for readability; substitute the launcher path when
running from elsewhere.
If neither interpreter can import requests, the tool says so and names the one
it tried, rather than failing with an import traceback. The fix is a virtualenv
beside the skill: python3 -m venv .venv && .venv/bin/python -m pip install requests, which the launcher then prefers automatically.
With no arguments at all it runs the overview preset for the last 7 days.
Add --dry-run to any command to print the exact request that would be sent,
with the token redacted, and send nothing. On the POST operation it prints the
full JSON body, so a dry run shows the whole query rather than its envelope. A
preset that issues several requests prints all of them, so errors --dry-run
shows both of its filter sets, and only the first page of each, because paging is
decided by what comes back. It sends nothing at all, not even the one GET that
resolves the owning account, so ownerId can print as
<read from the project at run time> with a line underneath saying where a real
run reads it. It works with no token configured, so it is the safe way to show a
user what a query will do before running it.
Error and log questions. These are the ones people ask in a hurry, so they come first:
| The user says | Run |
|---|---|
| "any errors in the last 30 minutes", "what is broken", "is my site erroring" | python3 -m vercel_insights errors --since 30m |
| "am I returning 500s", "server errors today" | python3 -m vercel_insights errors --since 24h |
| "show me the logs", "recent requests" | python3 -m vercel_insights logs --since 15m |
| "what is failing most", "group the errors" | python3 -m vercel_insights error-summary --since 6h |
| "errors on /api/checkout" | python3 -m vercel_insights errors --path /api/checkout --since 1h |
| "find the request that logged ECONNRESET" | python3 -m vercel_insights logs --search ECONNRESET --since 1h |
| "everything about request X" | python3 -m vercel_insights logs --request-id X --expand |
| "warnings too" | python3 -m vercel_insights logs --level error,warning,fatal |
| "errors on my preview deploys" | python3 -m vercel_insights errors --environment preview |
| "what about the 404s", "am I returning 401s" | python3 -m vercel_insights logs --status-code 4xx --since 1h (a 4xx is not counted as an error) |
errors counts a 5xx response, a crashed function, or a request that logged an
error or fatal line, and it issues two queries because neither filter finds the
other's rows. Read Reading a logs answer before you report what came back,
above all when nothing did.
Traffic questions:
| The user says | Run |
|---|---|
| "how is my site doing", "traffic this week", "give me the numbers" | python3 -m vercel_insights |
| "how did traffic move over the last month", "daily trend", "chart it by week" | python3 -m vercel_insights trend --since 30d (add --granularity week) |
| "top pages", "most viewed pages", "what are people reading" | python3 -m vercel_insights top-pages --since 30d |
| "top routes", "which route is hottest", "roll the blog posts up" | python3 -m vercel_insights top-routes --since 30d |
| "where is my traffic coming from", "who links to us", "referrers" | python3 -m vercel_insights referrers --since 30d |
| "which countries", "where are visitors located" | python3 -m vercel_insights countries --since 30d |
| "mobile vs desktop", "what devices" | python3 -m vercel_insights devices --since 30d |
| "which browsers", "how much Safari" | python3 -m vercel_insights browsers --since 30d |
| "which operating systems", "Windows vs Mac" | python3 -m vercel_insights operating-systems --since 30d |
| "which campaign worked", "utm breakdown", "did the newsletter land" | python3 -m vercel_insights campaigns --since 30d |
| "how many signups", "custom events", "conversions" | python3 -m vercel_insights events --since 30d |
| "which campaign drove signups" | python3 -m vercel_insights events --event-name signup --since 30d --group-by utmCampaign |
| "how many visitors in total", "unique visitors for the month" | python3 -m vercel_insights total --since 30d |
| "which projects do I have", "what can you see", "is analytics even on" | python3 -m vercel_insights --list-projects |
| "how did /pricing do" | python3 -m vercel_insights trend --path /pricing --since 30d |
Performance questions:
| The user says | Run |
|---|---|
| "how fast is my site", "core web vitals", "is my site healthy", "performance check" | python3 -m vercel_insights vitals --since 7d |
| "what is my LCP", "what is my CLS", "is my CLS bad", "how is my INP" | python3 -m vercel_insights vitals and read that row against its target |
| "which pages are slowest", "what is dragging the site down", "worst routes" | python3 -m vercel_insights slowest-pages --since 30d |
| "which pages are fastest", "what is already fine" | python3 -m vercel_insights fastest-pages --since 30d |
| "fail the build if it gets slower", "set a performance budget", "check against a threshold" | python3 -m vercel_insights vitals --budget lcp=2500 --budget cls=0.1 (exit 3 when exceeded) |
| "did my performance regress", "is it getting worse", "LCP over time", "since the last deploy" | python3 -m vercel_insights vitals-trend --since 30d --granularity 1d |
| "why is it slow on mobile", "mobile vs desktop speed" | python3 -m vercel_insights vitals-by-device --since 30d |
| "is it slow abroad", "speed by country", "how is it in India" | python3 -m vercel_insights vitals-by-country --since 30d |
| "how slow is the blog specifically" | python3 -m vercel_insights vitals --route '/blog/[slug]' --since 30d |
| "is that number trustworthy", "how much data is behind this" | python3 -m vercel_insights data-points --since 30d |
| "show me the worst case, not the typical case" | add --percentile 95 (or 90, 99) |
| "what is my Real Experience Score" | Not queryable through any API. Send them to the Speed Insights tab of the dashboard. |
Applies to more than one surface, and each row says which. That is not a matter of taste: a flag used outside the surfaces named exits 2 rather than being quietly ignored.
| The user says | Run |
|---|---|
| "traffic from the US only", "US visitors only" | add --country US (traffic and speed; the logs API records no country) |
| "mobile only" | add --device mobile (traffic and speed; a log row has no device) |
| "production only" | add --environment production (all three) |
| "narrow it to one page or one route" | add --path /pricing or --route '/blog/[slug]' (all three; both match exactly, so --search is the substring tool on logs) |
| "give me a CSV", "put it in a spreadsheet" | add --csv (not on overview, vitals or error-summary, which each print several tables) |
| "give me JSON", "pipe it to jq" | add --json |
| "what would that request look like" | add --dry-run |
Anything else on the account:
| The user says | Run |
|---|---|
| "what else can you measure", "what metrics are available" | python3 -m vercel_insights --list-metrics |
| "how many function invocations", "edge requests", "cache hit rate", "firewall blocks" | python3 -m vercel_insights --metric <id from --list-metrics> --aggregation sum |
Those last ones need the Observability Plus add-on; Web Analytics, Speed Insights and request logs do not. Without it they return an error no flag can fix, so say that rather than retrying.
Run python3 -m vercel_insights --list-presets for this table at any time.
| Preset | Surface | Groups by | Default limit |
|---|---|---|---|
overview (default) | web analytics | day, then requestPath, then referrerHostname (3 calls) | 5 per table |
trend | web analytics | day | 100 |
top-pages | web analytics | requestPath | 10 |
top-routes | web analytics | route | 10 |
referrers | web analytics | referrerHostname | 10 |
countries | web analytics | country | 10 |
devices | web analytics | deviceType | 10 |
browsers | web analytics | browserName | 10 |
operating-systems | web analytics | osName | 10 |
campaigns | web analytics | utmCampaign | 10 |
events | web analytics | eventName | 10 |
total | web analytics | nothing, one ungrouped count | n/a |
vitals | speed insights | nothing; one query per vital (5 calls) | n/a |
slowest-pages | speed insights | route, worst P75 LCP first | 10 |
fastest-pages | speed insights | route, best P75 LCP first | 10 |
vitals-by-country | speed insights | country | 10 |
vitals-by-device | speed insights | device_type | 10 |
vitals-trend | speed insights | time, 1d buckets by default | n/a |
metric | speed insights | nothing; any metric by id, for example --metric vercel.request.count | 10 |
data-points | speed insights | route, summing the *_count metric | 10 |
logs | request logs | nothing; one row per request, newest first | 50 rows |
errors | request logs | nothing; two queries merged and deduplicated | 50 rows |
error-summary | request logs | the same errors tallied by status, by route and by message (3 tables) | 200 rows |
On the three logs presets the limit counts rows, not groups, from 1 to 200, and nothing rolls up: whatever was left out is named in the footer instead. Their default window is shorter than everything else here, for the reason given under Time window.
Any explicit flag overrides a preset value, with three exceptions, all of them
presets that print more than one table. overview runs three queries of its own,
so --group-by, --event-property and --csv are rejected there. vitals runs
one query per web vital, so --group-by, --csv and --metric are rejected
there too: it already reports all five, so there is no metric to pick and no
single table to write. error-summary tallies the same errors three ways, so
--csv is rejected there and errors --csv is the single table to ask for. All
three exit with code 2 and name the preset to use instead: trend, top-pages
or referrers for traffic, vitals-trend, slowest-pages or
vitals-by-country for speed, and errors for one row per request.
The preset also fixes the surface, and the code enforces that in every direction: a Speed Insights option on a Web Analytics preset is a configuration error, a Web-Analytics-only option on a Speed Insights preset is one too, and so is a logs option on either analytics preset or an analytics option on a logs preset. None of them is ignored, so never reach for a flag from another column to "see if it helps".
Groups past the limit are not dropped: they roll into a single Others row,
and the table prints a line underneath saying so. That is a rule about grouped
tables. The logs presets have no groups, so rows past the limit are genuinely
left out, and the footer says so instead of hiding it.
--help prints every one with its defaults. These are the ones that change an
answer rather than its formatting. The Surface column is not advice, it is
what the code enforces: a flag used on a preset outside the surfaces named here
exits 2, with the reason it means nothing there and the presets where it does
work. A flag marked Speed Insights only therefore exits 2 on any traffic or
logs preset, and so on for the rest.
| Flag | Surface | What it does |
|---|---|---|
--limit N | all three | How many groups to show, 1 to 100. The rest roll into Others on Web Analytics; on Speed Insights it bounds grouped results per time bucket. An ungrouped query (total, vitals) has nothing to limit, so it goes unused there. On a logs preset it counts rows instead, 1 to 200, and rows past it are left out rather than rolled up. |
--granularity BUCKET | traffic and speed | Time bucket, in either vocabulary. week and year are Web Analytics only and a configuration error on a speed preset. Any bucket at all is an error on a logs preset, which answers with rows. |
--metric {lcp,inp,cls,fcp,ttfb} | Speed Insights only | Which web vital to report. A configuration error on a traffic preset, and on vitals, which reports all five. |
--percentile {75,90,95,99} | Speed Insights only | 75 by default, as on the dashboard. Higher asks about the slow tail. |
--aggregation NAME | Speed Insights only | sum, count, min, max, p90 and so on, instead of a percentile. Not with --percentile. |
--order-by {count,value}, --order {asc,desc} | Speed Insights only | Grouped queries only; on an ungrouped speed query they are an error too. Default count and desc, so a group with few measurements does not lead. |
--data-points | Speed Insights only | Report how many measurements were collected instead of the metric value, aggregated with sum. |
--all | Speed Insights only | Every project in the team instead of one. Mutually exclusive with --project. There is no equivalent on the traffic presets: compare those one --project at a time. |
--bucket-timezone IANA | Speed Insights only | Aligns 1d and 1mo buckets; timestamps stay UTC and a sub-daily bucket ignores it, with a warning. |
--dataset {visits,events}, --event-property NAME | Web Analytics only | Pick the custom events dataset and break it down by an event property. A configuration error on a speed preset, which has no datasets and no events. |
--level {error,warning,info,fatal} | logs only | Only requests that logged a line at one of those levels, comma separated. It matches log lines, not responses, so a 500 that printed nothing does not match it. |
--status-code CODE | logs only | An integer (500), a class (5xx, 40x), None for a request with no status recorded, or a comma separated mix of those. No comparisons: >=500 is refused, quoting the API's own rule. |
--source {serverless,edge-function,edge-middleware,static} | logs only | What served the request. Validated here rather than by the API, which answers an unknown value with 200 and zero rows. The source column can display serverless-middleware; pass that and it is rewritten to edge-middleware, the spelling that actually matches those rows. |
--search TEXT | logs only | Free text. Verified to match the request path; that it also searches log text is Vercel's documented behaviour and is unprobed here, because no test project had logged a line. Not a query syntax, so do not expect status:500 to filter by status: use --status-code for that. |
--request-id ID, --method POST, --branch NAME, --deployment dpl_... | logs only | One request, one HTTP method, one git branch, one deployment. A --method outside the standard set warns on stderr and is still sent, since a custom verb is legal; read the warning, because an unrecorded method comes back as zero rows rather than as an error. |
--expand | logs only | Print every full log line under its row instead of truncating the message to its column. Reach for it once a row is worth reading in full. |
--timeout SECONDS, --max-retries N | all three | 30 seconds and 3 retries by default. Only 408, 429 and 5xx responses and network failures are retried. A logs page took up to 6 seconds live, so do not lower the timeout for that surface. |
--verbose | all three | Diagnostics on stderr. Never the token. |
--list-presets, --version | all three | Print and exit 0, touching no network. |
vitals is the one to reach for when the question is broad. It issues one query
per metric because the API answers for one metric per request, and composes the
five answers into one table:
$ python3 -m vercel_insights vitals
Vercel Speed Insights: prj_9RkQm2vT7xLpN4dWbYcF3sJz
Range: 2026-08-09T05:33:49Z to 2026-08-16T05:33:49Z (UTC)
metric p75 target verdict
------------------------- ------ ------ ------------
Largest Contentful Paint 2.9 s 2.5 s over target
Interaction to Next Paint 184 ms 200 ms meets target
Cumulative Layout Shift 0.128 0.100 over target
First Contentful Paint 1.6 s 1.8 s meets target
Time to First Byte 412 ms 800 ms meets target
Lower is better for all five metrics.
The target is Vercel's published 'good' threshold, so the verdict is two tier: meets target or over target.
Real Experience Score is not queryable through this API; read it on the Speed Insights dashboard.
Six things to carry into how you report this to a user:
meets target or over target.
Vercel publishes one "good" threshold per metric and no boundary above it, so
there is no honest third tier. Do not invent "needs improvement" or "poor".
The dashboard's green, amber and red bands describe a derived 0 to 100 score,
not the raw millisecond or score value this tool reports.--percentile 90|95|99
asks about the tail instead.Grouped Speed Insights tables have no totals row and no share-of-total
column, unlike the traffic tables. That is deliberate: summing the P75 of six
countries is meaningless. data-points is the one exception, because a sum of
measurement counts genuinely adds up.
$ python3 -m vercel_insights slowest-pages --device mobile --limit 5
Vercel Speed Insights: prj_demo (slowest-pages, p75)
Range: 2026-08-07T09:00:00Z to 2026-08-14T09:00:00Z (UTC)
Filter: device_type eq 'mobile'
route p75_lcp data_points
----------------- ------- -----------
/blog/[slug] 4.1 s 1,830
/pricing 3.0 s 2,240
/dashboard/[id] 2.5 s 902
/docs/[[...slug]] 2.0 s 4,410
/ 1.2 s 8,822
Metric: vercel.speed_insights.lcp_ms (Largest Contentful Paint)
Target: 2.5 s or less
Lower is better for all five metrics.
The target is Vercel's published 'good' threshold, so the verdict is two tier: meets target or over target.
A percentile over few data points is not comparable to one over many, so read the value next to its data point count.
Note what --device mobile compiled to: device_type eq 'mobile', the Speed
Insights spelling. The same flag on a traffic preset compiles to
deviceType eq 'mobile'. Use the shorthand flags rather than writing raw
--filter clauses and the spelling is handled for you.
Speed Insights needs no Observability Plus: these metrics are available on the
query surface without it. It also collects on every deployed environment,
preview included, so unlike the traffic count endpoints there is no
production-only restriction; narrow it with --environment production when the
user means the live site.
Read this before quoting any of it. These rows carry text this skill did not write and cannot vet. A log line can hold the user's own secrets or another person's private data, and the only secret redacted here is this skill's own token. Quote the least that answers the question, and do not forward log output anywhere. Spelled out in the last two bullets of this section.
errors is the preset for "what broke". It issues two queries, one for 5xx
responses and one for error and fatal log lines, and merges them by request
id, because neither of those questions answers the other:
$ python3 -m vercel_insights errors --since 30m
Vercel request logs: acme-docs (errors, last 30 minutes)
Range: 2026-08-17T10:36:00Z to 2026-08-17T11:06:00Z (UTC)
Counted as an error: a 5xx response, a crashed function, or a request that logged an error or fatal line.
time level status method route source message
-------- ----- ------ ------ --------------------- ---------- ----------------------------------
11:05:19 error 500 POST /api/checkout serverless TypeError: Cannot read properties…
11:04:52 error 500 POST /api/checkout serverless TypeError: Cannot read properties…
11:02:41 fatal 200 GET /api/cron/sync serverless FATAL: connection pool exhausted
10:58:03 - 502 GET /api/documents/[slug] serverless (no log line: the response failed)
4 errors in 30 minutes: 2 x 500, 1 x 200, 1 x 502.
Most affected route: /api/checkout (2).
1 of them returned a non-5xx status and count as errors only because they logged an error or fatal line.
Add --expand for full messages, or --request-id to pull one request apart.
Six things to carry into how you report this, and the first three are the difference between a useful answer and a confidently wrong one.
errors leaves it
out. A 401 on /api/me is an unauthenticated request being turned away and
a 404 is a URL nobody has; counting those as faults would bury the 500s under
them. When someone does ask about them, logs --status-code 4xx asks for them
by name, and --status-code None finds requests where no status was recorded
at all.--level only matches requests that logged a line. The level is read off
the request's own log lines, so a 500 that crashed without printing shows -
in that column and no --level value will ever find it, while a request that
returned 200 whose handler logged a stack trace is invisible to
--status-code 5xx. That is the entire reason errors runs two queries. An
explicit --level or --status-code collapses it back to one, which is
sometimes what you want and always half the picture: your filter chose the
rows that come back, not this tool's own error query, and the output says so,
naming the filter in place of the error definition and counting "requests"
rather than "errors". errors --status-code 4xx is a list of 4xx responses,
not a list of faults. In error-summary a request that is an error
only because it logged is tallied under its real status, so a 200 row in a
table of errors is not a bug, and the footer says how many rows qualify that
way.errors run it adds that the result is the most recent N
of each kind rather than a global top N. Quote those caveats: a table cut at
its limit is not "the worst errors", it is the most recent ones, and a ranking
computed over them ranks them and nothing else.Authorization header, and on
this surface it is replaced by <redacted> wherever it turns up in a response,
a log message included. That rewrite is specific to request logs, because these
are the only rows that carry free text an application wrote; on the two
analytics surfaces nothing echoes it back to rewrite. That is the one string
this tool can recognise; the user's own secrets it cannot.When a single row is worth pulling apart, logs --request-id <id> --expand
prints every log line that request produced, worst level first, in full, and
marks any line Vercel itself truncated. It is still a windowed query, so widen
--since when the request is older than the preset's hour. --json carries every
field the API returned, including the ones the table has no column for, under
each entry's raw key; that one is the row as Vercel sent it save for this
skill's own token being rewritten out of it, so anything lifted out of it is the
same untrusted text as the rest.
There are two Web Analytics datasets. visits is page views, and it is the
default for every traffic preset except events. Use --dataset events (or the
events preset, which sets it) when the question is about something a person
did rather than a page they loaded: signups, checkouts, button clicks, form
submissions, anything sent through track() in @vercel/analytics.
Signs the events dataset is wanted: the user names an event ("signup", "checkout_started"), asks about conversions, or asks to break a result down by a property attached to an event ("signups by plan", "checkouts by coupon").
Three things only exist on the events dataset, and asking for them on visits
is a configuration error with an exit code of 2:
eventName dimension and the --event-name filter--event-property NAME, which adds eventData/NAME to the groupingeventData/... grouping written out longhandNone of them exist on Speed Insights at all: that API collects no custom events,
so --dataset, --event-name, --event-property and --flag on a Speed
Insights preset are configuration errors naming the reason.
The metric names differ too: visits returns pageviews and visitors, while
events returns count and visitors.
Custom events require a Vercel Pro plan or above. On a lower plan the query is legal and simply comes back empty.
Note on --event-property: it adds a second grouping dimension, so rows come
back split by both event name and property value, and each dimension gets its
own column named exactly as it was requested:
eventName eventData/plan count visitors % count
--------- -------------- ----- -------- -------
signup free 1,904 1,755 80.4%
signup pro 412 388 17.4%
signup enterprise 51 47 2.2%
--------- -------------- ----- -------- -------
TOTAL 2,367 2,190 100.0%
--csv emits the same two columns. In --json every row carries a groups
object mapping each grouped dimension to that row's label ("groups": {"eventName": "signup", "eventData/plan": "free"}), alongside key, which is
the first label only. Group by the property alone
(events --group-by eventData/plan) when the event name column is not wanted.
On the two analytics surfaces each filter flag adds one OData clause and all
clauses are joined with and. A comma-separated value becomes an in (...) set,
so --country US,CA,MX is one clause covering three countries.
Valid on all three surfaces, compiled to whatever the active one takes:
--path, --route, --environment {production,preview}.
Valid on both analytics surfaces, compiled to the right spelling for
whichever one is active: --country, --device. The logs API records neither,
so both are a configuration error on a logs preset rather than being ignored.
Valid on Web Analytics only: --browser, --os, --referrer,
--utm-source, --utm-medium, --utm-campaign, --event-name (events
dataset), --flag NAME=VALUE (repeatable). Using one while a Speed Insights
preset is active is a configuration error that names the reason: that API does
not collect the dimension at all.
Valid on request logs only: --level, --status-code, --source,
--method, --search, --request-id, --branch, --deployment. None of them
is an OData clause: this surface has no OData at all, and every filter travels as
a query parameter. Two consequences worth remembering:
--path and --route match exactly here, so --path /api finds nothing
when the real paths are /api/me and /api/checkout. --search is the
substring tool instead, over the request path; whether it also searches log
text is unprobed, because no test project had logged a line.--search takes free text, not a query syntax, so do not expect status:500
to filter by status: use --status-code for that. The field:value form
Vercel's own CLI help advertises does not filter here, which live probes
showed two ways: path:/api/me came back unfiltered, while level:error and
method:POST came back with nothing. Neither result is a filter working, and
what the server does with such a string was never established.--filter ODATA appends a raw clause verbatim on either analytics surface,
repeatable. On a logs preset it is a configuration error naming the query
parameter flags to use instead.
The two analytics APIs accept different operators, and this matters when writing
a raw --filter:
eq, ne, in, and, or, not, parentheses, and
startswith. It has no comparison operators: gt, lt, ge and le
are rejected by the API, so do not write them.endsWith and the numeric comparisons >,
>=, <, <=.Raw --filter text is passed through unvalidated on both analytics surfaces, so
a clause the target API does not accept comes back as its own 400 with Vercel's
message.
--since defaults to 7d, except on the logs surface: logs and errors
default to the last 1 hour and error-summary to the last 6 hours,
because runtime logs are retained for one hour on Hobby and one day on Pro, so a
7 day default there would usually report nothing and read as a healthy site.
--until defaults to now everywhere. A preset's default is only a default:
--since 30m or --since 24h overrides it as usual.
Both accept a relative offset (30m, 24h, 7d, 4w), now, today,
yesterday, an ISO date (2026-08-01), an ISO datetime
(2026-08-01T12:00:00Z), or Unix milliseconds. Everything is normalized to UTC.
A relative --since counts back from now, not back from --until.
The guaranteed reporting window depends on the plan and on the surface. For
analytics it is 1 month on Hobby, 12 months on Pro, 24 months on Web Analytics
Plus and Enterprise; a --since older than that is allowed but may return
nothing, and the tool warns on stderr past 24 months. Runtime logs are kept far
more briefly: 1 hour on Hobby, 1 day on Pro, 3 days on Enterprise, 30 days with
Observability Plus. Asking a logs preset about yesterday is legal and will
usually come back empty, which is exactly the answer Reading a logs answer
warns not to read as good news.
| Variable | Required | Meaning |
|---|---|---|
VERCEL_TOKEN | yes, except for --dry-run | Vercel access token, read scope is enough. Overridable with --token. |
VERCEL_PROJECT_ID | yes, except on a Speed Insights preset run with --all | Project ID or project name. Overridable with --project. |
VERCEL_TEAM_ID | no | Team ID for a team-owned project. Overridable with --team. |
VERCEL_TEAM_SLUG | no | Team slug instead of the ID. Never set both; it is an error. |
VERCEL_OWNER_ID | no | The account that owns the project, which Speed Insights needs as scope.ownerId and request logs as the ownerId parameter. Overridable with --owner-id. |
VERCEL_ORG_ID | no | Vercel's own name for the same account, written by vercel link. Read when VERCEL_OWNER_ID is unset. |
NO_COLOR | no | Set to any value to disable colour. |
These seven are the only variables the code reads. The owner is resolved in one
order: --owner-id, then VERCEL_OWNER_ID, then VERCEL_ORG_ID, then the team,
because a team is its own owner. So setting VERCEL_TEAM_ID for a team project
means neither owner variable is needed, while on a personal account a Speed
Insights or logs run with none of them set spends one extra request reading the
owner off the project record.
A team slug cannot stand in for the owner on those two surfaces: a slug is a
name and the API wants an account id, so --team-slug alone is a configuration
error there rather than a query answered for the wrong account. It still works
for Web Analytics presets.
--all, which queries every project in the team, is Speed Insights only: on a
traffic or logs preset it is a configuration error, so a project is always
required there. It is also mutually exclusive with --project.
| Code | Meaning |
|---|---|
| 0 | Success, including an empty result set. |
| 1 | The API returned an error, or the network failed after retries. |
| 2 | Configuration or usage error. Fix the command and rerun. |
| 130 | Interrupted. |
An empty result is a success, not a failure. It prints one line naming the
resolved range and the active filter. When a user sees that, the usual fixes are
a wider --since, a looser filter, or checking that the feature is actually
enabled on the project: Web Analytics and Speed Insights are separate
per-project switches, and each only has data from the moment it was turned on.
Request logs need no switch, and there the advice inverts: a wider window is as likely to be the problem as the fix, because the logs behind it may already have aged out. An empty logs answer over more than an hour prints the retention rule with it, and that sentence is the answer, not a footnote to trim.
The same API serves every Vercel metric. If the user asks about function
invocations, edge requests, cache behaviour, firewall actions or AI gateway
usage, run --list-metrics to see what their account can reach, then query by
id with --metric. Naming a metric is enough; no preset is needed.
Be straight about how wide that is. --list-metrics and --metric are not
confined to the errors, traffic and speed story this skill leads with: they reach
whatever the account's observability schema exposes, which was 96 metrics on the
account this was probed against. It is read-only and it is one fixed endpoint,
and it still means an account-scoped token gives this skill read access to
every metric that account can see, not only the web vitals. Say so if a user is
deciding how to scope a token, and point them at the narrowest scope that still
answers their question. Do not go exploring the schema unasked: run
--list-metrics when the user's question needs a metric this skill has no preset
for, not as a way to see what is there.
Two things to tell the user honestly. Every metric on this surface outside Web
Analytics and Speed Insights requires the Observability Plus add-on, so a plan
without it gets an error that no flag can fix. Request logs are not on this
surface and need no add-on, which is why an error question belongs to errors
rather than to vercel.request.count. And for those metrics this tool knows no
unit and no target, so it reports the number without a verdict; do not describe
such a value as good or bad on its own.
One Vercel account holds many projects and every query names exactly one, so when the user's request does not identify a project, or names one loosely ("the blog", "our marketing site"), list them first:
python3 -m vercel_insights --list-projects
That returns each project's name, its prj_ id, and whether Web Analytics and
Speed Insights actually hold data. Match the user's words against the names,
then pass either the name or the id to --project. Both work on every preset.
If a project shows off or empty for the feature being asked about, say so
rather than reporting an empty result as though the site had no traffic: off
means the feature is not enabled, empty means it is enabled but has collected
nothing yet, and they need different fixes.
The listing has traffic and speed columns and no logs column, because there
is no third per-project switch to report: Web Analytics and Speed Insights each
have one, and runtime logs do not. So an empty logs answer is never explained by
a feature being off, which is what leaves retention as the explanation to reach
for.
A project scoped token cannot read Speed Insights. Vercel's APIs scope
differently: Web Analytics takes a projectId and is project-level, while Speed
Insights is served by the observability API and scopes by account. A token
scoped to a single project therefore reads traffic fine and answers every Speed
Insights preset with 404 Observability Data not found. That message reads like
"no data" but means "this token cannot ask". The tool says so when it happens.
If a user hits it, tell them to create an account or team scoped token at
https://vercel.com/account/tokens; npx vercel@latest metrics schema shows
what the current one can reach.
Request logs scope by the owning account too, through an ownerId parameter they
require and cannot infer, so the same project scoped token is expected to get a
403 there rather than an empty answer, and the tool appends that explanation to
Vercel's own message when it happens. Only a team scoped token was ever available
to test this endpoint, so treat the project scoped case as reasoned from the
parameter rather than observed.
requestPath is the literal URL path (/blog/my-post). route is the
framework pattern (/blog/[slug]), which rolls many URLs into one row. Use
top-routes and slowest-pages when the user wants sections rather than
individual pages.requestPath on a
Speed Insights preset is an error telling you to write request_path, and the
reverse holds too. The shorthand filter flags avoid the whole problem.visitors summed across time buckets double counts a person who visited on
two days. For distinct visitors over the whole window use total.total report production traffic only, so
--environment preview with total is a configuration error. Group by
something (for example --group-by day) to reach preview data. Speed Insights
has no such restriction.--json and --csv are mutually exclusive. --csv needs a single table, so
it is rejected with overview, vitals and error-summary, each of which
prints several. On a logs preset --csv writes the rows to stdout and the
report's notes to stderr, so read both: the truncation and retention caveats
are only on the second stream, and CSV alone cannot tell a table cut at its
limit from a complete one.data-points before drawing a conclusion from a small route.--metric vercel.request.count grouped by status does:
without the add-on it answers 402 payment_required, which no flag fixes. The
metric surface knows how many 500s there were; the logs surface knows which
requests they were and what they printed. Reach for errors.--since.source column and --source do not share one vocabulary. A row can
display serverless-middleware, which the API takes as a filter and then
matches no rows: the spelling that matches those rows is edge-middleware.
Answering nothing is the dangerous failure here, not refusing, because zero
rows reads as "your site is fine". This tool accepts the displayed spelling and
rewrites it, so --source serverless-middleware works, and a refused
--source value names the mapping as well. Do not expect the two vocabularies
to line up in general.--source serverless really narrows anything. One live
probe returned a row set indistinguishable from unfiltered, including rows
whose only event source was static, while --source edge-middleware did
filter as expected. Nobody has probed it a second time, so do not present a
--source serverless result as a strict subset and do not claim the filter is
broken either. Read the source column of the rows that came back.README.md for setup and worked examples.docs/api-notes.md for the verified facts about all three APIs, including
every live probe behind the request logs claims here.docs/cli-contract.md for the authoritative interface.examples/example_outputs.md for fuller sample output.