← All docsTrack & automate

Projects & trends

Group runs of the same page into a timeline, watch five key metrics, and alert on regressions.

What a project is#

A project groups analyses of the same page into a timeline. Runs are keyed by the page's normalized URL (query string ignored), so re-analyzing /checkout next week lands on the same timeline as today's run — whether it arrived by upload, capture, or a schedule.

Trend metrics & delta badges#

Five metrics are tracked per page, each with its own sparkline and delta badge:

MetricBetter when
Performance scorehigher
Total transfer byteslower
JavaScript byteslower
Third-party transfer byteslower
Page load timelower

A delta badge compares the latest run to the one before it (with a percentage where meaningful) and is classified improved / regressed / flat by the metric's direction — a rising score is green, rising bytes are not. The timeline links every point back to its full report.

Regression alerts#

Every tracked metric has a threshold of its own, and each one is independent — set the ones you care about and leave the rest empty:

ThresholdUnitFires when
Score droppointsThe score falls by at least N points vs the previous run.
Transfer bytes growth%Total transfer grows by at least N%.
JavaScript growth%Script bytes grow by at least N%.
Third-party weight growth%Third-party transfer bytes grow by at least N%.
Page load time growth%The capture’s wall-clock span grows by at least N%.

A run that trips several thresholds produces one message listing every breach, in the order above. Breaches post a Slack-compatible message to the project's webhook and/or email a plain-text alert to the project's configured address — set either, both, or neither. You can do this when you create a project, or any time afterwards via "Edit alerts" on the project's row (also linked from its trends page); clearing a field turns that alert back off. Alerts are fire-and-forget: a webhook hiccup or a bounced email never blocks or fails the analysis itself.

The alert links straight to a comparison of the regressed run against the one before it, so you land on what actually changed — added requests, requests that got slower, and third-party growth — rather than just the new score. The diff is built from the two stored analyses, not from the raw captures (which are deleted once analyzed), so however far apart two runs are, the earlier one is still a valid baseline.

Slack notifications#

The webhook channel lives in each project's "Slack notifications" card. In Slack, create an incoming webhook — Slack app directory → your workspace → "Incoming WebHooks" → add one to the channel you want — then paste the https://hooks.slack.com/services/… URL Slack gives you and save. Press Send test message to confirm it lands: the card reports the result immediately, and afterwards shows when the last alert was delivered (or why the last one failed), so a dead webhook looks different from a quiet week. The test goes to whatever URL is in the field, so you can check a webhook before saving it — and re-checking after an edit tests the new URL, not the old one.

Two events can post to the channel. Regressions (on by default) fire when a run breaches this project's thresholds. Any analysis completes is off by default and posts the score plus the run's headline numbers — requests, transfer, load time, JavaScript and image weight, domains, third-party share, the slowest request and how many critical/high issues are open — for every run. Useful when a schedule is capturing the page on a cadence and you want the result in the channel either way. A regressed run posts the regression message only, never both.

The message is Block Kit, so Slack renders a header, the numbers that moved and a button through to the diff — but every message also carries a plain-text version, which is what Slack-compatible receivers (Mattermost, a homegrown handler) render. Any public https endpoint is accepted; it doesn't have to be Slack. Webhooks pointing at private or internal addresses are refused, at save time and again at delivery time. The exact fields are below — read them before wiring up a Slack workflow or your own handler.

What the webhook posts#

Harpoon sends a single POST with content-type: application/json and a Slack-compatible body: text, a one-element attachments array, and a flat field per number the message states (documented in full below).

FieldTypeWhat it carries
textstringThe whole message as plain text with Slack mrkdwn — always populated, on every event. This is what non-Slack receivers render.
attachments[0].colorstringThe colour bar: #dc2626 for a regression or a failing schedule, #d97706 for a run graded C, #16a34a for a healthy run, a recovery or a test message.
attachments[0].blocksarrayBlock Kit — a header, an optional context line naming the page, the score, a two-up grid with one field per metric (the regressed metrics on a regression; the run’s headline numbers on a completed run), and a link button. Rendered by Slack; ignored by most other receivers.

Alongside those, every number the message states is also sent as its own top-level key — so a receiver never has to parse prose to get the score, and a Slack workflow can map them as variables. The full field list is below.

The events produce these text bodies. A regression:

:warning: *Performance regression in Marketing site* on https://example.com/pricing
• Harpoon score dropped 12 pts (78 → 66).
• Total transfer grew 391 KB (1.1 MB → 1.5 MB).
<https://app.harpoon.solutions/compare/cmp_1|See what changed>

An analysis completing — the score line, the run's numbers, then the report link on its own last line. There is deliberately no findings list: the grade and the issue counts carry the verdict, and the detail belongs in the report the button links to.

Harpoon analyzed Marketing site — https://example.com/ — score 91/100 (A)
147 requests, 4.2 MB transferred in 3.42 s, 1.1 MB JS, 2.0 MB images, 19 domains, 38% third-party, slowest 1.83 s (cdn.example.com), 6 blocking paint, 2 critical / 5 high issues
https://app.harpoon.solutions/analysis/an_2

And the "Send test message" button:

Harpoon test alert for Marketing site. If you can read this, alerts for this project will reach this channel.

A schedule attached to this project posts a fourth payload of the same shape when the scan itself starts failing or recovers — no metrics in that one, because a failed scan produced none. The page line is dropped when a project's runs aren't page-keyed, and the score line degrades to "Analysis complete" if the run's analysis can't be loaded — so treat every line except the first as optional. Delivery gives up after 5 seconds; anything that isn't a 2xx counts as a failure and is shown on the card, and a redirect is never followed (a 3xx is recorded as a failed delivery, not chased to its target).

Every field in the payload#

These are the flat, top-level keys — the machine-readable half of the message, and the only part a Slack workflow can map to variables. Three rules hold for all of them:

  • Every key is present on every event. A value that doesn't apply is an empty string — never missing, never null. (Slack fails a workflow trigger whose declared variable is absent from the payload, so this is what keeps a workflow from breaking the first time a run comes back clean.)
  • Every value is a string, including the numbers — workflow variables are typed text. Parse them on your side if you need arithmetic.
  • Bytes are raw byte counts and times are whole milliseconds — the humanized "4.2 MB" and "3.42 s" appear only in text and blocks.
KeyExampleWhich events populate it
eventanalysis_completedAll. One of: regression, analysis_completed, monitor_failure, monitor_recovery, test.
projectMarketing siteAll. The schedule’s name on the two schedule-health events.
pagehttps://example.com/pricingRegression and completed runs, when the project’s runs are page-keyed.
score66Regression and completed runs (empty if the analysis couldn’t be loaded).
gradeDAs score.
previousScore78Regressions with a previous run to compare against.
scoreDelta-12As previousScore. Signed: negative is a drop.
regressedMetricsHarpoon score, JavaScriptRegressions. Comma-separated labels, in trend order.
requests147Completed runs.
transferBytes4404019Completed runs. Raw bytes.
scriptBytes1200000Completed runs. Raw bytes.
imageBytes2100000Completed runs. Raw bytes.
loadTimeMs3421Completed runs. Whole milliseconds.
domains19Completed runs.
thirdPartyPct38Completed runs. Whole percent of transfer bytes.
slowestMs1834Completed runs, when the analysis had any requests.
slowestHostcdn.example.comAs slowestMs. Host only — never the full URL.
blockingCount6Completed runs. Render-blocking resources found.
criticalIssues2Completed runs. Findings at that severity.
highIssues5Completed runs.
errornavigation timed out after 30smonitor_failure only.
urlhttps://app.harpoon.solutions/compare/cmp_1All. Where the message’s button points — the diff, the report, or your schedules.
shareUrlhttps://app.harpoon.solutions/share/tok_9Completed runs, only when the project opted into a public link.
Adding a key here is backwards-compatible; renaming or removing one would silently break any workflow that declared it, so treat this list as a contract. If you need a number Harpoon doesn't send, ask rather than parsing it out of text.

A complete analysis_completed body, with the Slack-only keys elided:

{
  "event": "analysis_completed",
  "project": "Marketing site",
  "page": "https://example.com/",
  "score": "91",
  "grade": "A",
  "previousScore": "",
  "scoreDelta": "",
  "regressedMetrics": "",
  "requests": "147",
  "transferBytes": "4404019",
  "scriptBytes": "1200000",
  "imageBytes": "2100000",
  "loadTimeMs": "3421",
  "domains": "19",
  "thirdPartyPct": "38",
  "slowestMs": "1834",
  "slowestHost": "cdn.example.com",
  "blockingCount": "6",
  "criticalIssues": "2",
  "highIssues": "5",
  "error": "",
  "url": "https://app.harpoon.solutions/analysis/an_2",
  "shareUrl": "",
  "text": "Harpoon analyzed Marketing site — …",
  "attachments": [ … ]
}

Triggering a Slack workflow#

A workflow built in Slack's Workflow Builder can start "from a webhook", which gives you a https://hooks.slack.com/triggers/… URL. Harpoon accepts it anywhere an incoming webhook URL goes. The catch is what a workflow can read: trigger variables are extracted from top-level JSON keys only, and each one must be typed as text, user ID, user email or channel ID. Nested structures can't be mapped.

That is exactly what the flat fields are for: score, project, url and the rest are top-level string keys, so each one can be declared as a Text variable and used in your steps. attachments is an array of objects and can't be mapped — Workflow Builder ignores it.

  1. In Workflow Builder, create a workflow and choose From a webhook as the trigger.
  2. Declare the variables you want, all typed Text — any key from the field list, e.g. event, project, score, grade, url. Every event sends every key, so any of them is safe to declare.
  3. Add your steps (send a message to a channel, open a form, page someone) and reference the variables where you want them. text is still there if you just want the whole message in one go.
  4. Publish the workflow, copy the webhook URL, and paste it into the project's "Slack notifications" card. Press Send test message — the test event carries the identical key set, so if it reaches the workflow, every alert will too.
Declare only keys from that list, spelled exactly. Slack fails a trigger whose declared variable is missing from the payload, and the failure is silent from Harpoon's side — the workflow goes on looking correctly configured while every alert bounces. A key you invent (severity, projectId) is never sent.

Branching on event is usually what you want: one workflow can page someone on regression and monitor_failure, and stay quiet on analysis_completed. Numbers arrive as strings, so a "greater than" condition on score needs a conversion step first.

text arrives as multi-line text containing Slack mrkdwn — bold markers around the headline and a <url|label> link on the last line. Steps that post it as a message body render that formatting; steps that treat it as a plain string (a form field, a title) will show the markers literally. The flat fields carry no markup, which makes them the better choice for a form field or a title.

Schedules build trends automatically#

Every schedule is attached to a project: one created from a project's Schedules card feeds that project, and one created anywhere else defaults to reusing (or creating) a project named after the scheduled page — pick a different existing project from the list if you'd rather. Every scheduled scan lands on the attached project's timeline and can trigger its regression alerts, no separate project setup required.

Turning findings into tickets#

  • Any analysis can generate ticket payloads from its recommendations — priority maps from P0P3, effort becomes a label.
  • When Harpoon is connected to your Jira or Linear workspace, tickets can be created directly from the report; otherwise you get the provider-neutral payloads to file wherever you track work. See Issue tracker (Jira & Linear).
Still stuck? Contact us or browse all guides.