Two products, one login. Analytics tells you what people do on your site. Monitoring tells you when it stops working.
Paste one line into your site's <head>, before the closing </head> tag. Replace YOUR_SITE_ID with the ID shown when you add a site in the dashboard.
<script defer data-site="YOUR_SITE_ID" src="https://dk.duckingstats.com/dk.js"></script>That's the whole install. The script is about 14 KB, loads with defer so it never blocks rendering, and starts recording pageviews immediately.
Put it in a shared layout, not on individual pages. Most platforms have one file that wraps every page — a theme header, a base template, a root layout component. One edit there covers the whole site.
Two checks, in order:
dk.js followed by a POST to /e returning 202.Then open your dashboard — the visit appears in realtime.
Seeing nothing? The tracker refuses to send on purpose in these cases:
You're on localhost, 127.0.0.1, a private network address (192.168.*, 10.*), a .local hostname, or opening the file directly from disk. This stops your own development from burning your quota. Test on a real domain.
You're using a headless or automated browser — Puppeteer, Playwright, Selenium and similar are filtered as bots.
You previously set localStorage.duck_ignore = 'true' in that browser to exclude yourself.
Pageviews are automatic. Anything else — a signup, a plan upgrade, a form submission — you record by calling duck() with a name you choose:
duck('signup');
duck('newsletter_subscribe');Attach extra detail with props so you can break the event down later:
duck('signup', { props: { plan: 'pro', source: 'pricing_page' } });Event names are free-form and are created the first time you send one — there's nothing to register in advance. Pick names you'll still recognise in six months and keep them consistent; signup and Signup are two different events.
For anything with money attached, add value. It is measured in minor units — cents, not dollars:
// a $49.00 sale
duck('purchase', { props: { plan: 'pro' }, value: 4900, currency: 'USD' });This is the easiest thing to get wrong. Passing 49 instead of 4900 records forty-nine cents. Values are rounded to a whole number, so 49.5 becomes 50. currency defaults to USD when you supply a numeric value.
Because the tag uses defer, a duck() call that runs very early would otherwise be lost. Add this stub above the script tag and early calls are queued, then replayed once the tracker loads:
<script>window.duck = window.duck || function(){ (duck.q = duck.q || []).push(arguments) }</script>An event on its own is just a counter. A goal is a saved definition that turns an event — or a page — into a tracked conversion, with unique converters and revenue totalled per day.
You don't need a goal to collect data. Events are recorded from the moment you send them. Creating a goal later still counts the history you've already gathered, because goals are computed from stored events rather than switched on at collection time.
| Kind | Matches | Use it for |
|---|---|---|
event | A custom event, by name | Actions: signup, purchase, demo_booked |
path | A pageview, by URL path | Destinations: /thank-you, /checkout/success |
A path goal is the one to reach for when the confirmation is simply "they landed on this page" — no code change needed, because the pageview is already being recorded. Use an event goal when the action doesn't have its own URL, like a button click inside a single-page checkout.
In your dashboard, find the Goals & conversions panel and click + New goal. Fill in three fields:
duck(), or a path like /thank-you.Save it and the goal immediately shows conversions and any revenue carried on the matching events. Remove a goal with the × on its row — that deletes the goal definition only, never the underlying events, so you can recreate it later and get the same numbers back.
Event names are matched exactly, and they're case sensitive. If your code sends duck('Signup'), a goal matching signup counts zero forever. Copy the name straight out of your code.
Start with one goal, not ten. The single question most sites need answered is "how many visitors did the thing I care about". Add funnels and secondary goals once that number is trustworthy.
React, Vue, Svelte, Next, Nuxt and similar frameworks change the URL without reloading the page. The tracker handles this automatically — it hooks the browser History API, so pushState, replaceState and back/forward navigation all record a pageview with no extra code.
Two cases need a flag:
| Situation | What to add |
|---|---|
Routing via #hash fragments | data-hash on the script tag |
| You want full manual control | data-manual, then call duck('pageview') yourself on each route change |
| Attribute | What it does |
|---|---|
data-site | Required. Your site ID. Without it the script does nothing at all. |
data-manual | Stop automatic pageviews. You call duck('pageview') when you want one. |
data-hash | Treat #hash changes as pageviews, for hash-routed apps. |
data-exclude | Comma-separated paths to skip, e.g. /admin/*,/preview. A trailing * is a prefix wildcard. Matches the path only — not query strings. |
data-api | Send to a different collector origin, for first-party CNAME setups. |
Alongside each pageview you get referrer, screen-size band, timezone, entry-page marker, time on page, scroll depth, engaged time (visible and being interacted with), and whether the visit bounced.
Not automatic: outbound link clicks, file downloads, form submissions and JavaScript errors. If you want those, send them yourself with duck().
To stop your own visits being counted in a particular browser, run this once in the developer console on your site:
localStorage.duck_ignore = 'true'It's per-browser and per-device. Remove it with localStorage.removeItem('duck_ignore').
Duck Stats analytics is cookieless. The tracker sets no cookies, reads no localStorage for identification, and does no fingerprinting.
Visitors are counted using a salted hash computed on our side, with the salt rotating daily and never stored in a reversible form. There is no identifier that follows someone from one day to the next, and nothing that follows them from one site to another.
The practical consequence: there is no persistent tracking identifier to disclose, which is what cookie-consent banners exist to cover. Your own legal obligations depend on your jurisdiction and what else you run — this is a description of how the tracker behaves, not legal advice.
A monitor is a check plus an assertion: we contact your service on a schedule and decide whether the answer is acceptable. Add one from the dashboard — give it a target, an interval, and what counts as healthy.
Monitoring is agentless. Checks run from our probe fleet and reach your service from the outside, the same way a real visitor does, so there is nothing to install on your servers.
| Type | Checks |
|---|---|
| HTTP(S) | Status code, response time, and optionally a keyword in the body or a JSON field. Can also warn before the TLS certificate expires. |
| Ping | ICMP reachability and round-trip time. |
| TCP port | Whether a port accepts connections. |
| DNS | A record resolves and returns what you expect (A, AAAA, CNAME, MX, NS, TXT, SOA, SRV, CAA, PTR). |
| Databases | MySQL/MariaDB, PostgreSQL, MongoDB, Redis, SQL Server. |
| Message queues | Kafka, RabbitMQ, MQTT. |
| Other | gRPC, SNMP, Docker container state, process running on a host. |
| Push | Your service reports in to us — see below. |
Keyword and JSON-query checks are options on an HTTP monitor rather than separate types, so one monitor can assert both "returned 200" and "the body still says healthy".
Check intervals depend on your plan — see pricing. The plan sets the minimum; you can always choose a longer interval.
If we can't reach it, invert the direction. Anything behind NAT, on a home connection, or inside a private network can't be probed from outside — use a push monitor instead, described next.
A push monitor reverses the usual direction: instead of us contacting your service, your service contacts us on a schedule. If the expected ping doesn't arrive in time, the monitor goes down and alerts fire.
This is the right tool for two jobs:
Create a push monitor in the dashboard and it gives you a unique URL. Have your job request that URL when it finishes successfully:
# at the end of your backup script
/usr/local/bin/backup.sh && curl -fsS "YOUR_PUSH_URL"Set the monitor's interval to how often the job runs. You can also report failure and latency explicitly:
| Parameter | Default | Meaning |
|---|---|---|
status | 1 | 1 = up, 0 = down. Lets a job report its own failure. |
msg | OK | Free text shown alongside the heartbeat. |
ping | 0 | How long the job took, in milliseconds. |
# report a failure with a message
curl -fsS "YOUR_PUSH_URL?status=0&msg=backup%20failed"The grace window is twice the interval, not once. A push monitor set to 60 seconds is marked down roughly two to three minutes after the last heartbeat — not at 60 seconds.
That is deliberate. Cron timing drifts, clocks skew, and a job that starts a few seconds late is not an outage. Doubling the window means an ordinary slow run doesn't page you at 3am. Set the interval to how often the job should run and let the grace window absorb the wobble.
A monitor that has never received a heartbeat reports No push received yet; one that has gone quiet reports No push received in time.
Put the ping after the work, and make it conditional. The && above matters: it only pings if the script succeeded. A ping that fires unconditionally at the top of the script proves the machine is awake, not that the job worked — which is the failure you actually wanted to catch.
The push URL contains a token. Treat it as a secret — it ends up in shell history, cron files and logs, and anyone holding it can report your job as healthy.
A monitor with no notification attached is a dashboard you have to remember to look at. Attach at least one channel to every monitor that matters.
Configure channels once in the dashboard, then attach them to individual monitors — so a noisy staging check can go to a chat room while production wakes someone up. A monitor can have several channels at once.
Supported channels include email, Slack, Discord, Telegram, generic webhooks, Pushover, Pushbullet, ntfy, Gotify, Matrix, Mattermost, Google Chat, LINE, Signal, WhatsApp, WeCom, Twilio SMS, PagerDuty, PagerTree, Opsgenie and Grafana OnCall.
Before marking a service down, checks are re-run from independent regions and must agree, so a single flaky network hop doesn't page you. Recovery is confirmed the same way.
On paid plans you can also set a re-notify interval, so an unacknowledged outage reminds you instead of alerting once and going quiet.
Test the alert, not just the monitor. The moment to discover a webhook URL is wrong is while you're setting it up — not during the outage it was meant to catch.
A status page publishes selected monitors so your users can check for themselves instead of asking you. You choose which monitors appear — internal checks stay private.
Each page gets a URL of the form status.duckingstats.com/status/your-slug. Pages can be password protected, restyled with custom CSS, given footer text, and set to refresh on their own. Higher plans allow more pages, custom domains and white-labelling.
Duck Stats runs its own probe fleet page at status.duckingstats.com/status/probes — a live example of the format.
The FAQ covers plans, intervals, retention and regions. If something here is unclear or wrong, tell us — documentation gaps are bugs.