view .md
Guides · Operate

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, and live streams in dev and production. It ends with the four signals to declare alert rules for, so a customer message becomes a failing line in under two minutes.

Time
≈ 10 min
Outcome
Structured logs streaming live from Email Guard's handlers, HubSpot correlation ids captured on every API failure, alert rules declared for the four signals that matter, and a debug workflow that takes a user report to the failing log line in under two minutes.
Prerequisites
  • A deployed HS-X project; this guide reads Email Guard, the app from the Getting started guide.
  • The hs-x CLI installed and logged in with a connected Cloudflare account (the logs API is account-scoped).

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. Declare alert rules for four signals (validate-email failures, suppression-list run health, trigger delivery, card-backend latency) in hsx.config.ts; today they ride the deploy record, and you evaluate them against the checkpoint panel.

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

EnvironmentWhere logs landHow you read them
Local dev (hs-x dev)Dev-server tail in your terminalAlready streaming. Cross-link: local dev guide.
Deployed (any env)Cloudflare Workers Logs + HubSpot app logshs-x logs --project-id <id> --json
Aggregate panelAnalytics Engine + checkpoint exemplarshs-x checkpoint --project-id <id> --json
External APMOTLP endpoint you configureOne runtime.request span per request when OTEL_EXPORTER_OTLP_ENDPOINT is set; see step 5.

The dev-server tail and hs-x logs are always on for linked deploys. Trace export to an external APM is an environment-variable switch on the Worker (step 5); there is no metrics export yet. 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 and console calls are mirrored into the same terminal in dev mode: HS-X injects a dev-only bridge into HubSpot’s generated extension bundle that relays them through a temporary app function, because the iframe runs under connect-src none and cannot reach localhost directly. (If you use @hs-x/sdk/ui’s logger, it additionally POSTs to the sidecar at http://127.0.0.1:9099/__hsx/log.) Either way, a click in the iframe and the backend call it triggered land in the same scroll.

hs-x dev
Expect
$ hs-x dev
  hs-x dev v0.4.1 · portal 46993937
  ────────────────

  │ ✓ server   http://127.0.0.1:8787  ready in 412ms
  │ ✓ workers  1 worker · 4 capabilities
  │ ✓ portal   46993937  dev override registered
  │ ✓ logs     streaming on http://127.0.0.1:9099
  │ ✓ tunnel   https://cool-mongoose-23.trycloudflare.com

  12:04:31.412  request   info   POST   /_hsx/invoke/email-health              200     61ms  ✓  local
  12:04:32.008  request   info   POST   /_hsx/invoke/validate-email            200     57ms  ✓  local

Each line carries a timestamp, its lane (request, backend, frontend, hubspot), a level, then the event. A 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 / .error from your handlers, rendered inline on the backend lane as 12:04:31.390 backend info validate-email verified {"contactId":"3301452","status":"deliverable"} — the capability id, then your message, then the fields you passed.
  • Card-side logger and console calls relayed from the iframe on the frontend lane in dev mode (in production builds they go to HubSpot’s platform logger, readable with hs-x logs --source hubspot).
  • 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 local dev guide.

Common dev-log issues

  • Card logs missing. HubSpot’s logger and console go through the injected dev bridge and a temporary relay function that HubSpot’s dev server executes locally, and HubSpot only does that for private, static-auth apps on platform 2026.03 or later. For any other app shape the startup checklist’s logs row reads backend + HubSpot events and a frontend logs off row states the reason; the card’s output still reaches HubSpot’s own dev overlay. When the relay is on, check the terminal for the frontend log relay active debug line; if it is absent, the bridge was not injected into the extension bundle for this session. Only @hs-x/sdk/ui’s logger talks to the sidecar directly (http://127.0.0.1:9099, --hsx-log-port to change), and that POST is the one an aggressive ad blocker or strict corporate proxy can block.
  • No request lines for portal traffic. The dev override did not attach, so HubSpot is still invoking the deployed handlers. Look for the portal row on the startup checklist; 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 --errors-only
hs-x logs --project-id <id> --json

The 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 (1–200), --trace / --invocation reads the logger lines for one invocation, --latest (-l) jumps to the newest invocation, --function <name> narrows to one capability, 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; invocation rows carry status and outcome, not a level), --sample 0.1 deterministically samples rows, --compact tightens the layout, and --follow (alias --tail) polls every --interval-seconds (1–60, default 3) until interrupted; --follow refuses --json. 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 logs shows no rows for a recently deployed Worker. Wait a short interval after deploy, then confirm your Cloudflare account is connected (re-run hs-x connect cloudflare) and that the project id is correct.
  • hs-x logs returns cloudflare_not_connected, cloudflare_credential_invalid, credential_decrypt_failed, or observability_permission_missing. The control plane could not query Workers Logs for that account. Reconnect Cloudflare for the first three; for the last, add Workers Observability Write to the stored Cloudflare credential. Cloudflare requires Write for the telemetry query endpoint even though HS-X only performs a read.
  • You need aggregate failure counts or latency percentiles. Use hs-x checkpoint --project-id <id> instead of hs-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. HS-X does not parse it for you; ctx.hubspot is HubSpot’s @hubspot/sdk client, whose APIError exposes status, the parsed JSON body as error, and the response headers. 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; error?: { correlationId?: string }; headers?: Headers };
  logger.error('contacts.update failed', {
    contactId: enrolledObject.id,
    status: failure.status,
    correlationId:
      failure.error?.correlationId ?? failure.headers?.get('x-hubspot-correlation-id') ?? undefined,
  });
  throw err;
}
Design preview

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.

Declare 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 mean a problem the user will see. defineApp takes an alerts block that names them: a notify target and a list of rules, each with a name, an expr, a window, an optional severity (info, warning, or critical), and an optional per-rule notify (a string or a list) that overrides the block-level target.

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' },
    ],
  },
});

What the block does today

hs-x check does not inspect the block. hs-x deploy validates its shape when it builds the control-plane plan request (so you need an account id in play), and hs-x deploy --plan --json echoes it under controlPlaneRequest.alerts; the control plane then stores it on the deploy record. The dashboard does not render it yet. Nothing evaluates the rules or sends to notify either: no rule evaluator or notifier ships in the runtime or the CLI. Until one does, the block is the written-down contract, and the numbers behind it come from hs-x checkpoint --project-id <id> --json (success rate, latency percentiles, failure counts over the last 24 hours) and hs-x logs --json; point your own scheduler or dashboard at those.

Why these four, and not the obvious alternatives

SignalWhy it’s the right one
validate-email failure rateThis 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 healthA sync that fails 100% is obvious. 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 deliveryThe 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 p95The 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. 800 ms 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 milliseconds, but the first request after a deploy can spike to 200 ms 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

Trace export exists today, switched on by environment variables on the deployed Worker rather than by config. Set OTEL_EXPORTER_OTLP_ENDPOINT (plus OTEL_EXPORTER_OTLP_HEADERS for auth, or HONEYCOMB_API_KEY, which the runtime turns into an x-honeycomb-team header) and the runtime exports one runtime.request span per request as service hsx-runtime, flushed through waitUntil so the export survives the isolate freezing. OTEL_SERVICE_VERSION and OTEL_DEPLOYMENT_ENVIRONMENT become resource attributes; every span also carries cloud.provider=cloudflare and hsx.worker=runtime. Without the endpoint (or a Honeycomb key) the wrapper is a passthrough.

# Worker secrets/vars on the deployed runtime
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
HONEYCOMB_API_KEY=hcaik_...          # or OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=...
OTEL_DEPLOYMENT_ENVIRONMENT=production
Design preview

There is no telemetry block on defineApp(...), no metrics export, and no child spans or sampling controls yet: the export is one request-level span per invocation. If you need Worker logs in an external provider, Cloudflare Workers Logpush forwards them 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.

Threshold noise

If a rule fires more than once a week and the breach isn’t a real incident, the threshold is wrong, not the signal. Widen the window, raise the threshold, and require the breach to hold for several minutes before it counts.

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. Note the scope: redaction.fields applies to ctx.logger payloads only; checkpoint exemplars pass through the generic redactor with no forced field list. That 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.