Post

Job Hunter Agent: Operations and Observability

Running Job Hunter Agent day to day: probes, logs, run history as the main metric, Slack as a heartbeat, and the observability gaps I know about.

Job Hunter Agent: Operations and Observability

Job Hunter Agent’s most useful monitoring signal turned out to be a Slack message: three times a weekday, a scheduled run reports what it found and what failed. If it doesn’t arrive, something’s wrong. The rest of this post is about the signals behind that message, and the ones still missing.

Application and infrastructure monitoring

LayerSignalWhere
PodReady, restarts, OOMKilledkubectl, Argo CD health, platform monitoring
App/readyz for probes; /healthz for LLM key, storage, LinkedIn connection, SMTP, schedule, SlackHeader dots in the UI
PipelineEach run’s status, counts and errorsRun history, Slack
AgentsEvery call’s success and erroragent_results table
LinkedInSession validitySidecar --status; discovery errors in runs

Logging and log retention

Both containers log to stdout: the app’s standard logging (scheduled run summaries as JSON, warnings for failed research, facts, news or Slack posts) and the MCP server’s request lines. The platform’s monitoring pack ships every pod’s logs to Loki on the mon cluster (about 500 lines an hour from this pod). Loki keeps other namespaces 15 days, but job-hunter gets 30, through a per-stream rule: enough to look back over a month of scheduled runs when reviewing the SLO targets.

1
{k8s_cluster_name="platform", k8s_namespace_name="job-hunter", k8s_container_name="app"} |= "scheduled run"

Run history in Supabase is the longer-term log of what the pipeline did.

Metrics and dashboards

The Totals panel and run history are the dashboard: jobs found, analyzed, drafted, pending review, approved, applied, rejected, and per run found / new / skipped / analyzed / drafted / below bar / duration. There’s no Prometheus endpoint; for one user, a table I already look at beats a Grafana panel I don’t.

Distributed tracing

Instrumented, not exported. The app creates OpenTelemetry spans for every request and every agent run (attributes like app.agent.type and app.agent.task), and tests check them. But production has no OTEL_EXPORTER_OTLP_ENDPOINT, so the spans stay in the process. Pointing it at the platform’s OTLP gateway is a one-line change and the fastest way to see where a 7-minute run spends its time.

Health checks

  • startupProbe, readinessProbe, livenessProbe all hit /readyz, which checks nothing outside the process. That’s deliberate: a slow LLM or a LinkedIn hiccup must not restart the pod (and kill a browser session in the middle of a run).
  • /healthz checks dependencies and is for people: it needs a signed-in session.

Alerting and escalation

EventHow I hear about itStill missing
Scheduled run finished with errorsSlack message with the first three errors—
Scheduled run didn’t happen, or keeps failingJobHunterProfileStale: no successful run for a profile in 4 days—
Runs failing outrightJobHunterRunSLOBreached (below 90% over 7 days)—
Daily upkeep failing (no backup, no keep-alive)JobHunterUpkeepFailing after 2 days—
Metrics missing (pod or PodMonitor gone)JobHunterMetricsMissing—
UI downEndpointDown from the blackbox probe; JobHunterAvailabilitySLOBreached over 7 days—
Pod OOMKilled / crash-loopingContainerOOMKilled (new, every namespace) and KubePodCrashLooping—
LinkedIn session expiredDiscovery errors in the runA specific Slack message asking for a sign-in

Escalation is to me; there’s no one else.

Availability and performance targets

Written down in ADR-0009, measured from the metrics the app serves on its own port (scraped by the platform’s collector) and the blackbox probe:

SLOTargetWindow
Scheduled runs complete (succeeded, or finished with some errors)90%7 days
Every scheduled profile has a fully successful runWithin 4 days (covers a weekend)Rolling
/readyz answers the blackbox probe99%7 days
Draft time per jobA few minutes with the local modelNo target

Why not 95%? Three profiles on weekdays make 15 runs a week. At 95%, a single failed run would break the SLO; at 90%, one failure is within budget and two are not. The window is 7 days because Mimir keeps 15 days of metrics. With only 15 events a week, multi-window burn-rate alerts would make no sense: one failure would trip every window. So it’s a plain ratio with a minimum of five runs, plus the freshness check. The targets are a first guess, to revisit after a month of data.

Operational runbooks

SituationDo
Discovery errors mention sign-in or a redirectRun the LinkedIn sign-in from the deployment runbook
“SSE stream ended without a response”Check the linkedin container for OOMKilled; it restarted mid-call
Every LLM call failsCheck the LiteLLM gateway and the virtual key; /healthz shows the key state
Profile saves fail after a deployA migration wasn’t applied; run it in Supabase
No Slack messagesPOST /notifications/slack/test; check the webhook in OpenBao and restart the pod after changes
1
2
3
4
kubectl --context k3d-platform -n job-hunter get pods
kubectl --context k3d-platform -n job-hunter logs deploy/job-hunter -c app --tail=50
kubectl --context k3d-platform -n job-hunter get pod -l app.kubernetes.io/name=job-hunter \
  -o jsonpath='{range .items[0].status.containerStatuses[*]}{.name} {.lastState.terminated.reason}{"\n"}{end}'

Incident and problem management

The incidents so far were all found by using the app, and each became a fix plus a written lesson:

What happenedCauseFix
LinkedIn returned HTTP 429 and dropped the sessionA session copied from another machineSign in inside the container only
Discovery failed mid-runChromium OOMKilled at 2 GiB3 GiB limit
Certificate wouldn’t issueDNS-01 checks hit the internal split-DNS zonePublic resolvers only
Company facts emptyWikidata 403 without a descriptive User-AgentNamed User-Agent

Capacity and cost monitoring

Costs are near zero, so the things to watch are limits: the sidecar’s memory (peaks during searches), Supabase free-tier usage, and LinkedIn volume (searches and pages per day, visible in run history as “found”). A monthly look is enough.

Checklist

  • OTLP endpoint set so traces reach the platform
  • Dead man’s switch for scheduled runs (JobHunterProfileStale)
  • Alert for OOMKilled or crash-looping pods (ContainerOOMKilled, KubePodCrashLooping)
  • SLOs written down, with alerts
  • Slack message for an expired LinkedIn session
  • Log shipping and retention confirmed (Loki, 30 days)
  • Runbook table tried for each row once

This post is licensed under CC BY 4.0 by the author.