Monitoring
An HS-X request crosses three trust boundaries before it returns a value to the user, and each boundary historically dropped the context: a UI extension log, a Worker log, and a HubSpot API response with no thread tying them together. This guide walks what Email Guard ships with today (a structured logger in every handler, HubSpot's correlation id on every API error, live streams in dev and production) plus the alerts that wake you up only when something is actually wrong, so a customer message becomes a failing line in under two minutes.
TL;DR — Every Email Guard handler gets a structured ctx.logger, every HubSpot API error carries a correlationId you can log, and both streams are readable: the dev terminal locally, hs-x logs against the deployed Worker. Use hs-x checkpoint for aggregate counts and latency. Wire alerts to four signals (validate-email failures, Worker 5xx, HubSpot 4xx/5xx, suppression-list run health) and ignore the rest.
Before you begin
A live request inside an HS-X project crosses three trust boundaries. The first is the HubSpot iframe: Email Guard's email-health card is sandboxed JS running inside HubSpot's chrome. The second is the public edge: the card calls the email-health backend on your Cloudflare Worker through hubspot.fetch, on the Worker's own URL. The third is the HubSpot API itself, where the Worker turns around and calls api.hubapi.com with the portal's access token. Three processes, three networks, three log streams.
The thing that used to make this painful is that each hop traditionally dropped the context. You'd see console.log('saved') in the card's devtools, then a separate Worker log somewhere, then a HubSpot rate-limit response with nothing tying them together. Reproducing a user's bug meant guessing which Worker invocation matched their click.
Today, the Worker side gives you a structured ctx.logger you can stamp with whatever request key makes sense: the contact id, the suppression-list cursor, or HubSpot's own correlationId (returned on error responses). Dev telemetry also carries a traceId on the request line and backend log lines, preserving x-hsx-trace-id when a caller provides it. Full trace propagation from the card through hubspot.fetch into outbound HubSpot calls is still a planned surface; see the design-preview callout in step 3.
The three places logs live
| Environment | Where logs land | How you read them |
|---|---|---|
Local dev (hs-x dev) | Dev-server tail in your terminal | Already streaming. Cross-link: dev mode guide. |
| Deployed (any env) | Cloudflare Workers Logs + HubSpot app logs | hs-x logs --project-id <id> --json |
| Aggregate panel | Analytics Engine + checkpoint exemplars | hs-x checkpoint --project-id <id> --json |
| External APM | OTLP endpoint you configure | Design preview; see step 5. |
The dev-server tail and hs-x logs are always on for linked deploys. External APM export is on the roadmap (step 5). For now, request-level logs live in Workers Logs, and aggregate runtime health lives in the checkpoint panel.
Read live logs in dev
When you run hs-x dev, the CLI boots the local dev server, a log sidecar, and the tunnel, then streams everything into one terminal: a request line per invocation (method, path, status, duration, source) and every ctx.logger.* call from your handlers inline. The card's logger (from @hubspot/ui-extensions) forwards into the same sidecar in dev mode, so a click in the iframe and the backend call it triggered land in the same scroll.
hs-x dev$ hs-x dev ▲ hs-x dev portal 46993937 → http://127.0.0.1:8787 * Ready in 412ms — 1 worker · 4 capabilities · portal 46993937 · log stream http://127.0.0.1:9099 · tunnel https://cool-mongoose-23.trycloudflare.com * * Press Ctrl+C to stop * POST /_hsx/invoke/email-health 200 61ms ✓ local POST /_hsx/invoke/validate-email 200 57ms ✓ local
Each request line carries the capability path, the status, and the wall-clock duration; a ✗ and a yellow or red status mark failures, and a └ detail line underneath carries the error text. local vs prod on the right tells you which side of the tunnel served it.
What the dev stream captures
- One request line per invocation (method, path, status, latency), whether it arrived from the portal through the tunnel or from
hs-x dev invoke. - Every
ctx.logger.info(...)/.warn/.errorfrom your handlers, rendered inline as[info] verified {"id":"3301452","status":"deliverable"}. - Card-side
loggercalls forwarded from the iframe in dev mode (in production builds they fall back to HubSpot's platform logger and stay out of your terminal). - Suppression-list runs you force with
hs-x dev invoke suppression-list, with the result envelope printed in full.
For more on what the dev environment is doing under the hood (hot reload, the tunnel, the invocation harness), see the dev mode guide.
Common dev-log issues
- Card logs missing. The iframe-to-sidecar forwarding can be blocked by aggressive ad blockers or strict corporate proxies. Check the browser devtools console for a failed connection to the log stream URL printed on the
Readyline (port 9099 by default). - No request lines for portal traffic. The dev override did not attach, so HubSpot is still invoking the deployed handlers. Look for the
portal <id>segment on theReadyline; if it is missing, restart with an explicit--portal <id>.
Read deployed logs
For anything that isn't on your laptop, use hs-x logs. It prints one merged timeline: Workers Logs rows from your deployed runtime ([cf]) interleaved with HubSpot's own app logs ([hs] — card and extension renders, production frontend logger output, webhook deliveries, API calls, OAuth authorizations). --source narrows it to all (default), workers, or hubspot.
hs-x logs --project-id <id>
hs-x logs --project-id <id> --since-minutes 60 --limit 100
hs-x logs --project-id <id> --trace <invocation-id>
hs-x logs --project-id <id> --filter source=action,status>=500
hs-x logs --project-id <id> --follow --filter level=error
hs-x logs --project-id <id> --jsonThe project id chooses the Worker. The account comes from --account-id, HSX_ACCOUNT_ID, or your active hs-x login account. --since-minutes controls the lookback window, --limit caps rows, --trace / --invocation reads the logger lines for one invocation, and --raw switches from invocation rows to raw tail rows. --filter accepts comma-separated predicates (key=value, key!=value, key~text, numeric comparisons like status>=500), --sample 0.1 deterministically samples rows, and --follow polls until interrupted. The HubSpot leg adds --source hubspot, --type <name> (log type, repeatable), --errors-only, and --app-id <id> (defaults to the project's .hs-x/hubspot.json binding); every row carries origin=workers|hubspot.
JSON output and jq
Pass --json and pipe the rows array into jq.
hs-x logs --project-id my-project --json \
| jq -r '.rows[] | select(.status >= 500) | "\(.timestamp) \(.method) \(.source) \(.status) \(.invocationId)"'Common log-stream issues
hs-x logsshows no rows for a recently deployed Worker. Wait a short interval after deploy, then confirm your Cloudflare account is connected (re-runhs-x connect cloudflare) and that the project id is correct.hs-x logsreturnscloudflare_not_connectedorobservability_permission_missing. The control plane could not query Workers Logs for that account. Reconnect Cloudflare or add the Workers Observability read permission to the stored Cloudflare credential.- You need aggregate failure counts or latency percentiles. Use
hs-x checkpoint --project-id <id>instead ofhs-x logs; it reads the 24-hour checkpoint panel.
Trace one request end-to-end
A live email-health render crosses all three boundaries in one pass — the card in HubSpot's iframe, your Worker on the public edge, and the HubSpot API the Worker calls back into:
On the Worker side, every handler receives a HandlerContext that includes a structured logger. Anything you log through it lands in hs-x logs --trace <invocation-id> for that request, and invocation rows expose the id you need to drill in. Email Guard's natural request key is the contact: stamp it on the way in and the verdict on the way out, then open the matching invocation trace.
worker.action('validate-email', {
// ...label, objectType, input, output: unchanged from the workflow-actions guide
async handler({ input, enrolledObject, hubspot, logger }) {
logger.info('validate start', { contactId: enrolledObject.id });
// ...the verification fetch...
logger.info('verified', { contactId: enrolledObject.id, status: verdict.status });
return ok({ status: verdict.status, score: verdict.score });
},
});When a HubSpot call fails, the error surfaces HubSpot's own correlationId (returned in error response bodies and as the x-hubspot-correlation-id response header). Log that id: it's the handle HubSpot Support uses to find the request in their access logs. Here that's the write-back inside validate-email:
try {
await hubspot.crm.objects.contacts.update(enrolledObject.id, {
properties: { email_health_status: verdict.status, email_health_score: String(verdict.score) },
});
} catch (err) {
const failure = err as { status?: number; body?: { correlationId?: string } };
logger.error('contacts.update failed', {
contactId: enrolledObject.id,
status: failure.status,
correlationId: failure.body?.correlationId,
});
throw err;
}A full end-to-end trace surface (a traceId minted automatically at the email-health card, propagated through hubspot.fetch to the Worker, then onto outbound HubSpot calls) is on the roadmap. Today, dev telemetry preserves a caller-provided x-hsx-trace-id or generates one per invocation, and production debugging still relies on the Workers Logs invocation id plus your natural request key and HubSpot's correlationId on errors.
Set up alerts on the four signals that matter
Alerting on "any error" trains you to ignore alerts. Alerting on p99 latency wakes you up because one user on a slow network exists. The four signals below are the ones that, when they cross a threshold, almost always indicate a real problem the user will see.
defineApp({
alerts: {
notify: 'pagerduty:my-service-key', // or slack:#alerts, email:oncall@co.com
rules: [
{ name: 'validate-email failure rate', expr: 'capability.validate-email.errors / capability.validate-email.invocations > 0.02', window: '10m' },
{ name: 'suppression-list run health', expr: 'sync.suppression-list.errors / sync.suppression-list.runs > 0.05', window: '15m' },
{ name: 'email-changed delivery', expr: 'trigger.email-changed.failures / trigger.email-changed.deliveries > 0.02', window: '10m' },
{ name: 'email-health backend p95', expr: 'capability.email-health.duration.p95 > 800ms', window: '30m' },
],
},
});hs-x deploy --plan --json includes this alerts block in the control-plane deploy plan, so the dashboard and deploy record see the same alert contract the code declares.
Why these four, and not the obvious alternatives
| Signal | Why it's the right one |
|---|---|
validate-email failure rate | This is the capability marketers build workflows on; a 2% failure rate means real contacts moving through workflows unverified. The usual causes are an expired EMAILCHECK_API_KEY or a missing contact write scope, both fixable in minutes once seen. |
suppression-list run health | A sync that fails 100% wakes you up. A sync that fails 5% of runs is a real signal too (usually a rate limit or a malformed row, not a transient blip), and every failed run is suppressed addresses your portal doesn't know about yet. |
email-changed delivery | The trigger is the silent path: no marketer is watching a workflow history for it. Failed deliveries mean stale verdicts on exactly the contacts whose addresses just changed. |
email-health backend p95 | The card fetches a fresh verdict on render, so this latency is user-visible on every contact record. p95 is the sweet spot: p99 fires on cold starts and is too noisy, p50 hides regressions. 800ms keeps the card under HubSpot's perceptible-lag line. |
A Worker-wide 5xx rate (above 1%) and the HubSpot 4xx/5xx rate (above 2% over 10 minutes; 429 means back off harder, 5xx means check status.hubspot.com) sit underneath all four as the coarse backstops.
What not to alert on
- Any single error event. One thrown exception in 24 hours is not a page-worthy signal; it's a Sentry issue.
- Cold-start latency. Cloudflare Workers cold-start in single-digit ms, but the first request after a deploy can spike to 200ms once. Alerting on this teaches you to ignore the dashboard.
- "Sync completed" as a positive event. Heartbeats belong in a dashboard, not in an alert channel.
Export traces and metrics to an external APM
OTLP export (a telemetry block on defineApp(...) that forwards traces and metrics to Honeycomb, Datadog, Grafana Cloud/Tempo, or any OTLP/HTTP receiver) is planned but not shipped. No OTLP exporter, sampler, or service_name configuration exists in the CLI or runtime today.
In the meantime, if you need an external APM, use the Cloudflare Workers Logpush integration to forward Worker logs to your provider; that path is supported at the Cloudflare layer independently of HS-X.
Resolve common monitoring issues
The handful of issues that come up most often, with the fix that works.
Logs not appearing in hs-x logs
Two causes, ordered by frequency. First, the newest deploy has not emitted queryable Workers Logs rows yet; wait briefly and retry. Second, the control plane cannot read your Cloudflare account: cloudflare_not_connected means reconnect Cloudflare, and observability_permission_missing means the stored credential needs Workers Observability read access. If the command returns no error and no rows, double-check --project-id and widen --since-minutes.
Alert noise
If you're getting paged more than once a week and the page isn't a real incident, the threshold is wrong, not the alert. Widen the window, raise the threshold, and require the breach to hold for several minutes before firing.
Secret values accidentally logged
Don't log full token values, raw HubSpot responses, or unfiltered request bodies. When you need to log something derived from a secret (for example, to confirm which verification key a portal is using), log a short prefix, never the whole value. The same goes for refresh tokens, email addresses, and customer PII; ironically for Email Guard, the email address itself is the PII, so prefer logging the contact id over the address wherever the id is enough.
logger.info('verification call ok', {
keyHint: String(env.EMAILCHECK_API_KEY).slice(0, 6),
status: res.status,
});Use the SDK's redact(value, { fields }) helper when you need to log a shaped object, and add redaction: { fields: ['customerName', 'note'] } to defineApp(...) for fields that must always be scrubbed from runtime logger payloads. The generic redactor already removes token keys, email addresses, phone-shaped strings, and bearer/PAT/HS-X token strings.
For deeper guidance on token and secret hygiene, see the secrets guide.
Where next
- How to · Grow → Rate limits in production — the next layer up: once you can see what's slow, learn how HS-X's batching and backoff keep you under HubSpot's per-portal limits.
- How to · Build → Dev mode — the dev-server side of the story; what the local tail is doing under the hood and how the WebSocket tunnel forwards UI logs.
- Reference · Secrets — how tokens are stored, rotated, and kept out of your log stream in the first place.
