Skip to main content
Restricted Access: This documentation is only accessible to @tenzo.ai and @salv.ai email addresses.

Overview

Datadog monitors for ATS sync health and outbound ATS request-queue health. Slack channels: Related tickets: TEN-350 (sync alerting), TEN-502 (stuck CREATING_APPLICATION), TEN-576 (reason-tagged abandon / hang alerts). Slack format: alerts are one-line headers with links here. Recoveries do not post to Slack (Datadog UI only). Investigate in the alert thread.

Compact Slack notifications

To keep #dd-sync-alerts and #dd-ats-request-queue readable, alerts use three layers: Datadog still adds a short attachment title, metric value (metric monitors), and built-in action buttons (Mute, Declare incident, Create case, Investigate with Bits). Mute is enabled via mute_buttons: true; the other buttons are native Datadog Slack app features. Re-apply monitor messages + compact Slack settings (requires SALV_DD_API_KEY / SALV_DD_APP_KEY with monitor-write and manage-integrations):

#dd-sync-alerts

Kombo ATS sync failed (webhook)

Monitor: 309657484 · Query: KomboSyncNotSucceeded log count > 0 in 30m, per ats_integration_id What to check
  • Kombo integration health / sync logs for this ats_integration_id
  • sync_state on matching logs: failed, partially_failed, authentication_failed, etc.
  • Logs
authentication_failed webhooks are often recurring Kombo auth drift — treat separately from one-off sync failures.

ATS sync queue enqueue failed

Canonical metric: ats.sync.queue.errors (publisher-side; every recorded publish failure, including permanent skips) Monitor: 309657487 · Query: ats.sync.stream.errors{error_type:publish*} > 0 in 30m, per integration_id + sync_type The monitor still matches the dual-write of ats.sync.stream.errors (error_type:publish*). Prefer ats.sync.queue.errors in new dashboards; the monitor query can switch later without a code change. Tags
  • class:retry — send failed, cycle timeout, or unexpected publish exception. Cursor is not advanced; the next cycle retries.
  • class:permanent — message cannot fit Service Bus (reason:message_too_large). Cursor is advanced so the queue cannot wedge on an unsizable payload.
  • reason: send_failed | message_too_large | publish_timeout | publish_exception
  • Also tagged with sync_type, integration_id, org_id, ats_provider, topic
What to check
  • Service Bus health / publish errors for this integration
  • Sync controller logs around job/applicant/candidate/tearsheet/placement queue publish
  • Permanent skips: logs with error.kind:AtsSyncPublishPermanentSkip
  • Metric
  • Companion dual-write: stream.errors publish*

Cursor advance rule

Every sync type decides whether to move its cursor with one rule, evaluated top to bottom, first match wins. The rule lives in server/ats/sync_watermark.py (decide_watermark_advance). The cursor always moves to the sync start time, never the end time, so records modified during the run fall inside the next window. A publish timeout or exception never reaches the rule: the phase exits before deciding, the cursor is simply not written, and the timeout logs a warning while a non-transient exception logs an error with a traceback. A job publish exception also cancels the applicant and tearsheet phases running alongside it. When the job publish completes but leaves its cursor held (retryable item failures), the applicant, tearsheet and placement phases still run to completion, and the cycle is then reported as failed for that integration. The tearsheet and placement stream publishers send one message at a time and stop at the first failure, so they never produce permanent skips.

ATS sync queue lag > 1 hour

Monitor: 309657489 · Query: ats.sync.queue.lag_seconds > 3600 in 15m, per integration_id + sync_type What to check
  • Service Bus subscription backlog / worker health for this sync type
  • Whether this integration is publishing faster than workers can process
  • Metric
  • Companion: ats.sync.queue.subscription.active_messages
Chronic lag on a single integration may indicate a disabled consumer or stale metric — verify active_messages before deep-diving.

ATS sync hang abandon

Monitor: 314970801 · Query: ats.sync.queue.abandoned{class:hang} > 0 in 15m, per integration_id + sync_type + reason Fires when a sync worker (or the session worker) abandons a message for a hang-class reason. Retry-class abandons (ats_fetch_failed, transient sync errors, client-init failures, …) emit the same metric but do not Slack — they are normal Service Bus redelivery. lock_lost is included at > 0: production had 0 "Lost message/session lock during process_batch" logs in the 7d before this monitor shipped. Large multi-chunk batches are allowed to overrun 900s; if lock_lost becomes noisy, raise that reason’s threshold rather than dropping the rest of the hang class. This is the signal for applicant/candidate 600s bulk-fetch timeouts (reason:ats_fetch_timeout). The duration monitor below does not catch those — they return ABANDON around 600s, under the 720s duration threshold.

Which monitor fires

Reason → log grep

What to check
  • Metric explorer: ats.sync.queue.abandoned{env:production,class:hang} grouped by reason, sync_type, integration_id
  • Companion: ats.sync.queue.handler.duration_seconds and queue lag for the same integration
  • Job/tearsheet/placement fetch errors without a 600s wrapper surface as lock_lost and/or duration, not ats_fetch_timeout

ATS sync handler near session lock

Monitor: 314970802 · Query: ats.sync.queue.handler.duration_seconds > 720 in 15m, per integration_id + sync_type 80% of lock_renewal_seconds=900. Catches batches still running toward lock expiry (and slow completes). Does not fire for the 600s applicant/candidate fetch-timeout path — use hang abandon reason:ats_fetch_timeout for that. Duration is now emitted on session-worker exception paths (lock_lost, unhandled process_batch) as well as successful handler returns. What to check
  • Whether this integration’s receive batch / chunk size is too large for the 900s session lock
  • Hang abandon for the same integration_id (lock_lost vs still-in-progress)
  • Metric

Candidates stuck in transient Tenzo stage

Monitor: 309683123 · Query: ats.candidate.stuck_transient_stage.count > 0 in 15m, per org_id + stage

What we detect

Stage entry age is the created_at of the latest matching tenzo_stage_history row where new_stage equals the CCI’s current tenzo_stage (ordered by history_seq desc). If no history row exists, we fall back to candidate_campaign_info.updated_at. Soft-deleted CCIs and campaigns are excluded. Only non-deleted rows are counted. The policy list in code is extensible (DEFAULT_TRANSIENT_STAGE_STUCK_POLICIES in server/ats/stuck_transient_stage_metrics.py); only CREATING_APPLICATION ships initially. Do not add AWAITING_RESUME_REVIEW here until that alert is intentionally scoped.

Metrics

Emitted every 5 minutes from the queue server (skipped when LOCAL or test). Tags: org_id, stage. (integration_id is omitted — it lives in Cosmos, not on the CCI row used for this query.)

What to check

  • List stuck rows via candidate_campaign_ats_sync_dao.list_stuck_transient_stage_candidates (see Ops inspection below)
  • Applicant sync lag: ats.sync.queue.lag_seconds{sync_type:applicant,env:production}
  • Logs: Deferred CCI link for native-fallback, remains in CREATING_APPLICATION, Applicant sync worker timed out bulk-fetching applications
  • Metric

#dd-ats-request-queue

ATS request queue failure rate > 5%

Monitor: 309673813 · Query: (http_error + timeout + exception) / completed > 5% in 30m, only if ≥100 completed requests, per integration_id What to check
  • ATS/provider health and 4xx/5xx responses for this integration
  • Queue depth / capacity: ats.request_queue.depth, ats.request_queue.rate_limiter.capacity_remaining
  • Request-queue service logs for this integration_id

ATS request queue HTTP errors spike

Monitor: 309673821 · Query: ats.request_queue.http_error > 10 in 30m, per integration_id Absolute spike alert — catches high-volume integrations with many failures even if rate stays under 5%. What to check
  • Request-queue logs for HTTP error responses (Kombo passthrough vs direct provider)
  • Provider status pages / incident history
  • Whether failures cluster on one endpoint (e.g. stage writebacks)

ATS request queue timeouts

Monitor: 309673814 · Query: ats.request_queue.timeout > 0 in 15m, per integration_id What to check
  • ats.request_queue.depth and in_flight for this integration
  • Attachment / long-running requests monopolizing workers
  • Request-queue service logs

ATS request queue exceptions

Monitor: 309673815 · Query: ats.request_queue.exception > 0 in 15m, per integration_id What to check
  • Request-queue exception logs for this integration
  • Provider connectivity / DNS / TLS issues
  • Recent deploys of the ATS request-queue service

ATS request queue depth high

Monitor: 309673819 · Query: ats.request_queue.depth warn >15 / critical >50 in 15m, per integration_id What to check
  • Worker pool / rate-limit capacity for this integration
  • Attachment storms / sync storms
  • ats.request_queue.in_flight and capacity_remaining

ATS request queue rate-limit capacity exhausted

Monitor: 309673820 · Query: avg ats.request_queue.rate_limiter.capacity_remaining < 1 in 15m, per integration_id What to check
  • Whether 429s are appearing in request-queue logs
  • Sync storm vs normal traffic for this integration
  • Provider RPM budget in rate-limiter config
Often informational during heavy sync — correlate with failure-rate and depth monitors before acting.

Ops inspection (stuck transient stage)

DAO helper (preferred)

Read-only, bounded list (default limit=100), oldest first:
Org-wide summary (counts + max age):

Equivalent SQL sketch

Implementation pointers

This path does not use incident.io or operational_alerts/.