# Heartbeat

A Heartbeat monitor has a secret **ping URL**. Your job calls it with `GET` when it has finished successfully:

```
https://dev.tickingora.com/ping/<token>
```

## How signals are counted

- Each scheduled window is a separate run: `[start, end)`, start included, end excluded.
- **One** signal inside the window is enough for `success`. All signals are recorded and counted (`signal_count`).
- No signal until the end of the window → `missed`, an incident opens and you get an alert.
- There is no "late" status: a signal after the window never changes it. A signal outside any window is recorded as *outside of schedule* and changes nothing.
- The receive time decides — the moment Tickingora accepted the request.

The response is `200 OK` with a plain-text body (`OK`, `OK (outside of schedule)`, `OK (monitor paused)`) and `Cache-Control: no-store`. An unknown or rotated token gets `404`.

## Send it after success — never before

The ping means "the job **completed**". Put it after the work, and only on success.

Two rules for every example below:

1. **The job decides, not the ping.** If the job fails, no ping is sent.
2. **Monitoring must not change the job's result.** If Tickingora is unreachable, the failed ping is reported, but the job still ends as successful — otherwise a monitoring outage would turn your successful backups into "failed" ones (and possibly trigger retries).

Also never print the ping URL in error messages: it is a secret.

```bash
#!/usr/bin/env bash
set -euo pipefail          # stop on the first error — the ping is never reached
pg_dump mydb | gzip > /backups/mydb-$(date +%F).sql.gz
curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2
```

### Cron: make the URL available to the job

Cron starts jobs with a minimal environment: variables exported in your `~/.bashrc` or interactive shell are **not** there, and the default shell is `/bin/sh` (use `.`, not `source`). Keep the URL in an environment file that only the job's user can read:

```bash
sudo install -d -o root -g backup -m 750 /etc/tickingora
sudo install -o backup -g backup -m 600 /dev/null /etc/tickingora/backup.env
sudoedit /etc/tickingora/backup.env
```

```bash
# /etc/tickingora/backup.env  (mode 600, owner backup)
TICKINGORA_PING_URL=https://…/ping/…
```

Then either load it in the crontab line and chain with `&&`:

```cron
0 2 * * * . /etc/tickingora/backup.env && /opt/jobs/backup.sh && { curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2; }
```

The `{ … || … ; }` group keeps a failed ping from failing the line — but do **not** write `backup.sh && curl … || true`: that would also hide a failed backup.

…or, cleaner, use a small wrapper and keep the crontab simple:

```bash
#!/bin/sh
# /opt/jobs/backup-and-ping.sh
set -eu
. /etc/tickingora/backup.env
/opt/jobs/backup.sh                       # set -e: a failure stops here, no ping
curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2
```

```cron
0 2 * * * /opt/jobs/backup-and-ping.sh >> /var/log/backup.log 2>&1
```

You can also put `TICKINGORA_PING_URL=…` as a line at the top of the crontab — simpler, but then the secret lives in the crontab, readable by the user and visible in `crontab -l`. Never commit the env file. Check it in the job's own environment: `sudo -u backup sh -c '. /etc/tickingora/backup.env && curl -fsS "$TICKINGORA_PING_URL?test=1"'`.

`-f` makes curl fail on HTTP errors, `-m 10` limits the time, `--retry 3` survives short network glitches. curl's own error messages contain only the host name, not the token.

### PHP

```php
function runImport(): void { /* … */ }

runImport(); // throws on failure, so the ping below is skipped

// A failed ping must not fail the job; @ also keeps the URL out of PHP warnings.
if (@file_get_contents(getenv('TICKINGORA_PING_URL'), false, stream_context_create(['http' => ['timeout' => 10]])) === false) {
    error_log('Tickingora ping failed');
}
```

Laravel scheduler — `rescue(..., report: false)` keeps a failed ping from failing the task **and** from writing the exception (whose message contains the URL) to the log:

```php
// config/services.php: 'tickingora' => ['ping_url' => env('TICKINGORA_PING_URL')],
Schedule::command('import:bank')->dailyAt('06:05')
    ->onSuccess(fn () => rescue(
        fn () => Http::timeout(10)->retry(3, 2000)->get(config('services.tickingora.ping_url')),
        fn () => Log::warning('Tickingora ping failed'),
        report: false,
    ));
```

### Python

```python
import os, sys, requests

def main():
    run_import()  # raises on failure — no ping
    try:
        requests.get(os.environ["TICKINGORA_PING_URL"], timeout=10).raise_for_status()
    except requests.RequestException:
        print("Tickingora ping failed", file=sys.stderr)  # not the exception: it contains the URL

if __name__ == "__main__":
    main()
```

### Node.js / TypeScript

```ts
await runSync(); // throws on failure — no ping
await fetch(process.env.TICKINGORA_PING_URL!, { signal: AbortSignal.timeout(10_000) })
  .then((res) => { if (!res.ok) throw new Error(); })
  .catch(() => console.error("Tickingora ping failed")); // never rethrow, never log the URL
```

### systemd

```ini
[Service]
Type=oneshot
ExecStart=/opt/jobs/backup.sh
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null ${TICKINGORA_PING_URL}
EnvironmentFile=/etc/tickingora/backup.env
```

`ExecStartPost` runs only if `ExecStart` succeeded. The `-` prefix tells systemd to ignore the curl exit code: without it, an unreachable Tickingora would mark the unit as **failed** even though the backup succeeded. curl's error still appears in the journal (with the host name only). Keep `EnvironmentFile` readable only by root (systemd reads it before dropping privileges): `chmod 600`.

## Testing the connection

Add `?test=1` to check the token and connectivity from the job's machine. The test signal is recorded (the dashboard shows "test signal received") but **never** counts for a window, opens or closes incidents:

```bash
curl -fsS "$TICKINGORA_PING_URL?test=1"
# OK (test signal, not counted)
```

## Limits

- 60 signals per minute per ping URL and 600 per minute per IP address; above that the response is `429`.
- Only the first 100 signals per window are stored individually; the rest are counted.

## Rotating the token

If the URL leaks (logs, a public repository) or you lost it, rotate it — the old URL stops working at once and the new one is shown in the response (or in the dashboard: **Generate new URL**):

```bash
curl -sS -X POST https://dev.tickingora.com/api/v1/monitors/42/rotate-token -H "Authorization: Bearer $TICKINGORA_API_KEY"
```

The ping URL is a secret: keep it in an environment file or a secret manager, not in code. Tickingora stores only a hash of the token (so it cannot show an old URL again — rotate instead) and never writes the URL to access logs.
