dumbtv
dumbtv가 ADB로 Fire TV에서 광고·추적·업데이트 알림을 제거, 루트·앱 변경 없이, 무료 오픈소스
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Ship Salesforce Event Monitoring data into Grafana Loki.
sf2loki is a long-running Python/asyncio service that ingests Salesforce Real-Time Event
Monitoring (RTEM) streaming events via the Pub/Sub API (gRPC + Avro) and stored event objects via
SOQL, then pushes them to Loki with strict label-cardinality discipline. It targets Grafana Cloud
Loki, self-hosted Loki, and local Alloy (loki.source.api).
See the architecture docs for the full architecture, frozen seams, and label strategy.README 전문 보기 ↗Ship Salesforce Event Monitoring data into Grafana Loki.
sf2loki is a long-running Python/asyncio service that ingests Salesforce Real-Time Event
Monitoring (RTEM) streaming events via the Pub/Sub API (gRPC + Avro) and stored event objects via
SOQL, then pushes them to Loki with strict label-cardinality discipline. It targets Grafana Cloud
Loki, self-hosted Loki, and local Alloy (loki.source.api).
See the architecture docs for the full architecture, frozen seams, and label strategy.
allow_overlap).
A separate opt-in ApexLog source (sources.apexlog) streams Apex debug logs via the Tooling
API for developer debugging — off by default, one API call per log body (see
sources: ApexLog). The Pub/Sub
source also carries your own custom platform events (/event/My_Event__e) and Change Data
Capture channels (/data/AccountChangeEvent) — any explicit topic, no engine change (see
sources: Pub/Sub — custom events & CDC).job, source, event_type, sf_org_id,
environment); everything high-cardinality (user_id, source_ip, replay_id, …) goes to
structured metadata or the JSON line — never labels. A startup guard fails fast on stray labels.
Structured-metadata promotion is configurable globally and per ELF event type; per-type labels
is an explicit opt-in escape hatch for the rare low-cardinality column you want as a stream label.batch.max_line_bytes, default 256 KiB) that truncates an oversized line before push so one fat row
can't get its whole batch rejected.replay_id / per-object watermark checkpointing to a local JSON file or
an S3-compatible object store (sf2loki[s3] — no persistent volume needed); at-least-once
delivery, structural backpressure (no silent drops). Optional active-passive HA via a shared
file lease with commit fencing.2 × queue_max_bytes.otelcol.receiver.otlp); plus /healthz, /readyz, structured logs, graceful shutdown.sf2loki doctor runs a live end-to-end preflight (auth, permissions,
entitlements, Pub/Sub reachability, a Loki test write); sf2loki backfill loads historical
EventLogFile data; a suite of Grafana dashboards and a Grafana-managed alert + recording-rule
pack ship in deploy/grafana/.Full documentation is published at m7kni.io/sf2loki. Key pages:
gcx.gcx apply workflow, and the metric-suffix requirement.ghcr.io/rknightion/charts/sf2loki) for the active-passive HA pair.sf2loki ships as a container image and as a source checkout. It is deliberately not
published to PyPI, so there is no pip, pipx or uvx route: the container needs no Python on
the host at all, and a source checkout needs Python 3.14+ because the package uses 3.14
language features.
| Use case | Install | Notes |
|---|---|---|
| Run the daemon (recommended) | docker pull ghcr.io/rknightion/sf2loki:latest | The long-running service. Multi-arch image, non-root, slim - see Run with Docker. |
| CLI / setup tooling | docker run --rm ghcr.io/rknightion/sf2loki:latest --help | The image entrypoint is the CLI, so --check, doctor, backfill and config run straight out of it - handy during Salesforce app setup before any infra exists. |
| As a library / from source | git clone then uv sync --locked | See Development. Optional s3, gcs and k8s extras install with uv sync --extra <name>. |
docker run --rm ghcr.io/rknightion/sf2loki:latest --version
# validate config + wiring, no network calls
docker run --rm -v "$PWD/config.yaml:/etc/sf2loki/config.yaml:ro" \
ghcr.io/rknightion/sf2loki:latest --check --config /etc/sf2loki/config.yaml
# live preflight (auth, entitlements, Loki write)
docker run --rm -v "$PWD/config.yaml:/etc/sf2loki/config.yaml:ro" \
ghcr.io/rknightion/sf2loki:latest doctor --config /etc/sf2loki/config.yaml
The container is the right target for the always-on ingestion daemon and for the one-shot CLI
surfaces (doctor, --check, backfill) you run by hand around setup and troubleshooting. Reach
for the source checkout when you want the package as a library, or when you need one of the
optional extras, which the published image does not carry.
The service authenticates server-to-server, no interactive login. Pick one flow via
salesforce.auth_mode:
jwt_bearer (default) — a private key signs a short-lived JWT assertion, Salesforce returns an
access token. Most secure: no shared secret ever leaves your secret store. Setup is steps 1–5 below.client_credentials — a consumer key + secret; simplest to set up (no keypair, certificate, or
user pre-authorisation). See Client Credentials flow.Both reuse the same External Client App shell and the same integration-user permissions (step 4); the JWT bearer walkthrough follows. See the auth guide for the protocol-level detail (JWT claims, token endpoint, why each scope/toggle is set the way it is).
JWT bearer uses an asymmetric keypair. You upload the public certificate to Salesforce (it uses it to verify your signed assertions); the private key is mounted into the pod (it signs them). The private key never leaves your secret store; the certificate is not secret.
# 2048-bit RSA private key -> stays in your secret store, mounted at salesforce.private_key_file
openssl genrsa -out server.key 2048
# self-signed X.509 public cert (valid 10y) -> uploaded to Salesforce, never deployed
openssl req -new -x509 -key server.key -out server.crt -days 3650 -subj "/CN=sf2loki"
| File | Secret? | Where it goes |
|---|---|---|
server.key | yes | mounted read-only (e.g. ./secrets) → salesforce.private_key_file (or salesforce.private_key). Never upload it. |
server.crt | no | uploaded to the External Client App (OAuth Settings → JWT Bearer Flow). Not deployed with the service. |
The cert is self-signed on purpose — Salesforce only needs the public key to verify signatures; there's no chain/CA validation in this flow. Rotate by generating a new keypair and uploading the new cert.
Setup → External Client App Manager → New External Client App. (External Client Apps are the path Salesforce recommends and, from Spring '26, requires for new apps in place of Connected Apps.) Field by field:
Basic Information
sf2loki. API Name auto-fills; leave it.API (Enable OAuth Settings)
App Settings
https://login.salesforce.com/services/oauth2/callback.Manage user data via APIs (api) and Perform requests at any time (refresh_token, offline_access). Leave everything else in Available: Access the identity URL service (id…), web, Full access (full), chatter_api, visualforce, and all Data
Cloud / platform scopes (cdp_segment_api, cdp_identityresolution_api,
cdp_calculated_insight_api, sfap_api, interaction_api, cdp_api). refresh_token is required
even though this flow never issues or uses a refresh token — see
the auth guide for why. openid
is needed only if you leave salesforce.org_id unset; set org_id in config to avoid needing it.openid is requested and an ID token
is consumed; the connector does neither).Flow Enablement — tick exactly one:
Security — leave every default as-is (Require secret for Web Server Flow / …Refresh Token Flow
/ Require PKCE ticked; refresh-token rotation/idle-TTL/IP-allowlist and named-user JWT access tokens
unticked). These toggles govern flows and refresh-token behaviour this connector doesn't use — see
the auth guide if you want the
reasoning for each.
Then: OAuth Settings → JWT Bearer Flow → upload server.crt, Save, and copy the Consumer
Key (App Settings) → this is salesforce.client_id.
JWT bearer has no interactive consent, so the user named in the sub claim must be pre-authorised.
On the app's Policies tab (admin-owned) → OAuth Policies → Permitted Users = "Admin approved
users are pre-authorized", then assign the app to a Permission Set that the integration user
holds. Optional hardening: add a login-IP restriction here if your pod egress IPs are stable.
ApiAnomalyEvent).No Event Monitoring add-on? Expect a short ELF menu. Without the Shield/Event Monitoring add-on, orgs get only the free EventLogFile subset: Login, Logout, API Total Usage, Apex Unexpected Exception, and the CORS/CSP-violation + hostname-redirect types — Daily interval only, 1-day retention. An
event_types: ["*"]wildcard on a free org silently yields just those (that's discovery working correctly, not a bug), andinterval: Hourlyneeds the add-on's hourly opt-in. The full ~70-type catalogue and RTEM streaming channels require the add-on.
Set salesforce.environment to production or sandbox (this derives the login URL), or set
salesforce.login_url explicitly to a custom My Domain URL (it takes precedence over environment).
Then set salesforce.client_id to the Consumer Key, salesforce.username to the integration user
(jwt_bearer only), and the private key via salesforce.private_key_file.
Prefer your My Domain URL over the generic hosts. For
client_credentialsit's mandatory (see below). Forjwt_bearerthe genericlogin.salesforce.comstill works as the JWTaud, but Salesforce is tightening this: Spring '26 removed legacy hostname redirects, and External Client Apps rejecttest.salesforce.comas anaud— so settingsalesforce.login_urltohttps://yourorg.my.salesforce.com(or...sandbox.my.salesforce.com) is the future-proof choice for both flows.
Topic availability depends on your Shield/Threat-Detection entitlements. Topic inclusion/exclusion is operator config (
sources.pubsub.topics+include/excludeglobs); defaults stay conservative.
Simpler than JWT bearer — no keypair, certificate, or user pre-authorisation. Set
salesforce.auth_mode: client_credentials and supply a consumer secret instead of a private key.
sub claim and no per-user pre-authorisation — the Run-As user replaces both.)Manage user data via APIs (api); refresh_token is not required for this flow.salesforce.auth_mode: client_credentials, salesforce.client_id = Consumer Key, and
the Consumer Secret via salesforce.client_secret_file (or client_secret). username and the
private key are unused in this mode.salesforce.login_url MUST be your My Domain URL (e.g.
https://yourorg.my.salesforce.com) — Salesforce rejects the client_credentials grant at the
generic login.salesforce.com / test.salesforce.com endpoints, so the environment-derived
default cannot work for this flow. sf2loki fails fast at config load if you leave it generic.Trade-off: client credentials transmits a shared secret (symmetric); JWT bearer never sends a secret over the wire (asymmetric key). Both yield an access token that works identically for the Pub/Sub, REST/SOQL, and EventLogFile paths. See the auth guide for the protocol-level detail.
Neither flow returns expires_in or a refresh token — the access token's real lifetime is your
org's session timeout (Setup → Session Settings), which can be as short as 15 minutes. sf2loki
handles expiry reactively (re-mints on 401/UNAUTHENTICATED and resubscribes from the stored
replay_id — no data loss), so a short timeout shows up as periodic reconnect churn, not an outage:
a sawtooth on sf2loki_pubsub_reconnects and sf2loki_auth_refreshes roughly every
session-timeout interval is your org's timeout at work, not a fault. To reduce the churn, raise the
integration user's session timeout (a profile-level Session Settings override works) and set
salesforce.token_ttl to match so proactive re-mints land before Salesforce kills the session.
Config loads from a YAML file and/or environment (SF2LOKI_* with __ nesting; env overrides YAML
overrides defaults). Secrets are injected from *_file paths or ${ENV} interpolation; a missing or
unreadable secret is fatal at startup. See config.example.yaml for a runnable
annotated example, and docs/config-reference.md for the complete
generated reference (every key, type, default, and description).
Both config.example.yaml and docs/config-reference.md are generated from the Pydantic config
schema — do not hand-edit them. Regenerate after changing config models with just gen-config, or
run just gen for every committed generated artifact (or sf2loki config example / sf2loki config reference directly). CI fails the build if either file drifts from the schema.
# run locally against a config file
uv run python -m sf2loki --config config.example.yaml
# validate config + wiring (secrets, label allowlist, source-overlap guard) without
# any network calls — exits 0 (ok) or 1 (invalid)
uv run python -m sf2loki --config config.example.yaml --check
Note:
config.example.yamlis a template — it references${ENV}placeholders (e.g.${SF_CLIENT_ID},${GC_LOKI}) and*_filesecret paths that must exist before even--checkpasses. Export the referenced env vars and put real files at the secret paths (or point the*_filekeys at your own), then run--check.
What sf2loki needs from your Grafana Cloud stack, and where to find it:
logs:write scope (add metrics:write if you enable OTLP telemetry). One token can serve both
Loki push and OTLP push. This goes in sink.loki.auth_token_file (and is reused for telemetry by
default).https://logs-prod-<zone>.grafana.net/loki/api/v1/push (→ sink.loki.url) and the numeric
User / tenant id shown there (→ sink.loki.tenant_id).https://otlp-gateway-<zone>.grafana.net/otlp (sf2loki needs the full signal path,
.../otlp/v1/metrics → service.telemetry.endpoint) with its own numeric instance id (→
service.telemetry.basic_auth_user).service.telemetry.basic_auth_user defaults to the Loki tenant_id when unset,
which 401s on Grafana Cloud; set it explicitly (see
config.docker.yaml's GC_OTLP_USER vs GC_LOKI_USER).sf2loki --check only validates configuration offline (secrets resolve, labels are legal, sources
don't overlap) — it never touches the network. Once you have real credentials in place, run the live
preflight instead:
$ sf2loki doctor --config config.yaml
name status detail
config PASS configuration and wiring valid
auth PASS flow=jwt_bearer instance_url=https://myorg.my.salesforce.com org_id=00D5g000000ABCDEAU
permissions PASS EventLogFile describe OK (View Event Log Files granted)
pubsub:/event/LoginEventStream PASS topic reachable
entitlement WARN org produces 42 Hourly EventType(s) total; no Hourly files found for ReportExport — check Event Monitoring entitlement or the type name
loki PASS pushed 1 test line in 87ms (the only write this command performs)
state PASS state directory /var/lib/sf2loki is writable and lockable
limits PASS DailyApiRequests 14312/15000 remaining
7 passed, 1 warnings, 0 failed, 0 skipped
It authenticates for real, checks the integration user's permissions, probes Pub/Sub topic
reachability, reports which configured EventLogFile types the org has actually produced files for,
and pushes exactly one test log line to Loki (labelled source=sf2loki-doctor) to confirm the
write path end-to-end — that Loki push is the only write doctor ever performs. Exits 0 if
nothing FAILed (WARNs are fine — e.g. a configured EventType the org hasn't produced yet), 1 if
anything FAILed. Add --json for machine-readable output in CI.
Checks run in order, and later checks that depend on an earlier one SKIP automatically instead of
failing confusingly — e.g. if auth FAILs, permissions/pubsub/entitlement/limits all report
SKIP (they need a token), but loki and state still run since they don't depend on Salesforce
auth at all.
See the sources guide for recipes — polling arbitrary custom objects, ingesting login history / setup audit trail, the either/or-per-category rule, PII redaction & sampling, and cost controls (rate caps + the daily byte budget).
One sf2loki process can ingest several Salesforce orgs into one shared sink — MSPs, ISV partners, and
anyone running prod + sandboxes no longer need N containers, N configs, and N state volumes for what is
logically one job. Replace the top-level salesforce:/sources: with an orgs: list; the sink, state
store, coordinator, and service settings stay shared (one pipeline, shared streaming/bulk lanes,
one Loki tenant):
orgs:
- name: prod # becomes the `org` label + checkpoint key prefix (org=prod:)
salesforce: { ... } # exactly today's salesforce block, per org
sources: { ... } # per-org source selection
- name: emea
salesforce: { ... }
sources: { ... }
sink: { ... } # shared
state: { ... } # shared store; keys are namespaced org=<name>:
service: { ... } # shared
Set either top-level salesforce: (single-org) or orgs: (multi-org), never both — and org
names must be unique (letters, digits, _, -). Every entry gets an org stream label plus its own
sf_org_id and environment labels (they can differ per org); the overlap guard runs per org, so the
same category on two different orgs is fine. Single-org configs are unchanged and bit-identical.
Operational notes:
TokenProvider and
Salesforce clients, so the existing rate/throttle guardrails and (optional) org-limit metrics apply
per org. Set salesforce.limits.enabled: true under an org to poll that org's limits.degraded: org <name> auth failing readiness reason, and retries auth reactively — recovering on its
own without a restart.org=<name>:; the first configured
org also falls back to the old unprefixed keys on load, so a deployment upgraded from single-org to
multi-org resumes from its existing state file and migrates forward with no manual step.doctor and backfill operate on one org — they default to the first configured org; pass
--org <name> to pick another (they print a note when multiple orgs are configured).See examples/presets/multi-org.yaml for a full two-org example.
examples/presets/ has ready-to-merge config fragments for common setups (merge
the relevant keys into your config.yaml alongside sink/state/service — see
config.example.yaml for the full schema):
multi-org.yaml — ingest two Salesforce orgs (prod + emea) from
one process into one shared sink via the orgs: list.custom-object-polling.yaml — SOQL-poll an
arbitrary custom object (e.g. MyAudit__c) via sources.eventlog_objects.event-log-objects.yaml — SOQL-poll Salesforce Big
Objects (the stored RTEM *Event/*EventStore family, e.g. LoginEvent, ApiEvent) via
sources.eventlog_objects with big_object: true. Big Objects reject ORDER BY ASC, so this flag
is required for them; standard/custom objects (LoginHistory, MyAudit__c) must leave it unset.
Same either/or-per-category overlap rule applies — don't also stream or ELF-ingest the same
category unless sources.allow_overlap is set.login-history.yaml — ingest login activity via
LoginHistory polling, with the LoginEvent//event/LoginEventStream/ELF Login alternatives
shown as commented blocks (pick exactly one — they're the same overlap-guard category).setup-audit-trail.yaml — ingest admin/config change
history via SetupAuditTrail polling.custom-platform-events.yaml — stream your own
custom platform events (/event/My_Event__e) and Change Data Capture channels
(/data/AccountChangeEvent, /data/MyChannel__chn) via sources.pubsub. No engine change —
the source subscribes to any explicit topic. Note custom/CDC events count against event-delivery
allocations (RTEM streams don't), and CDC bitmap fields ship unexpanded — see
.All metrics push via OTLP/HTTP. Set service.telemetry.enabled: true and service.telemetry.endpoint
(a Grafana Cloud OTLP gateway, e.g. https://otlp-gateway-<zone>.grafana.net/otlp/v1/metrics, or a
local Alloy http://alloy:4318/v1/metrics). Basic auth defaults to the Loki sink's
tenant_id/auth_token (Grafana Cloud uses one stack credential for Loki and OTLP); use
service.telemetry.auth: none for an unauthenticated in-cluster Alloy. Enable Salesforce org-limit
metrics (API usage, storage, streaming events, …) with salesforce.limits.enabled: true.
Metric-name suffixes. Instruments are created unsuffixed in code (e.g.
sf2loki_events_ingested), but appear in Prometheus/Grafana with the OpenTelemetry→Prometheus
suffixes _total / _bucket / _count / _sum (e.g. sf2loki_events_ingested_total,
sf2loki_ingest_lag_seconds_bucket). Grafana Cloud's OTLP endpoint adds these by default. If you
route metrics through your own OpenTelemetry Collector or Grafana Alloy, keep add_metric_suffixes
(a.k.a. AddMetricSuffixes) enabled on the Prometheus exporter — the connector-health dashboard
and the connector alert rules query the suffixed names and go silently blank otherwise.
A set of hand-authored Grafana dashboard-schema-v2 dashboards (Salesforce security, API,
Apex/performance, an overview, and the connector's own health) plus a Grafana-managed alert +
recording-rule pack live in deploy/grafana/ — apply them with gcx; see
deploy/grafana/README.md and docs/observability/alerts.md.
By default sf2loki persists checkpoints to a local JSON file, which needs a mounted volume that survives restarts. For stateless deployments (Fargate, Cloud Run, ECS with ephemeral storage) point sf2loki at an S3-compatible object store instead — no volume needed:
state:
store: s3
s3:
bucket: my-sf2loki-checkpoints
key: sf2loki/state.json # default; one object per sf2loki instance
region: us-east-1 # omit to use the AWS default-chain region
# endpoint_url: http://minio:9000 # any S3-compatible provider (MinIO/R2/Ceph)
Requires the s3 extra, which the published image does not carry (its build runs a plain
uv sync, so no optional dependency is installed): get it from a source checkout with
uv sync --extra s3, or build a derived image that adds it. Credentials come from the standard AWS
default chain (env vars, task role, shared config). Commits are conditional writes (ETag
compare-and-swap): if a second instance is pointed at the same bucket/key and races a commit, it
fails fast with a conflict error instead of silently clobbering the other's checkpoints — the
object-store analogue of the file store's flock. Two instances must not share a checkpoint object,
with one exception: an active-passive HA pair (below), where commit fencing guarantees only the
leader writes. Write rate is one full-object GET+conditional-PUT per checkpoint flush (~= flush
rate, not event rate).
Google Cloud Storage works the same way (Cloud Run with no volume) — set state.store: gcs and
the gcs extra (uv sync --extra gcs from a source checkout; not in the published image):
state:
store: gcs
gcs:
bucket: my-sf2loki-checkpoints
object_name: sf2loki/state.json # default; one object per sf2loki instance
# service_file: /etc/sf2loki/secrets/gcs-sa.json # omit to use Application Default Credentials
Auth is Application Default Credentials by default (workload identity / metadata server / GOOGLE_APPLICATION_CREDENTIALS);
set service_file only for an explicit key. The compare-and-swap uses GCS generation preconditions
(ifGenerationMatch) instead of an ETag, so the same fail-fast split-brain protection applies.
sf2loki backfill is a one-shot CLI for pushing historical EventLogFile data into Loki — useful
when you enable ingestion on an org that already has weeks of ELF history you want in Grafana, or
to refill a gap after an outage. It reads the same config file as the daemon but keeps its own
checkpoint file (a -backfill sibling of state.file.path), so it's safe to run alongside the
running service and is resumable if interrupted.
sf2loki backfill --config config.yaml --since 2026-05-01 --until 2026-06-01 \
--event-types Login,API --interval Daily
--since/--until are YYYY-MM-DD (UTC); --until defaults to now. --event-types defaults to
the types configured under sources.eventlogfile.event_types (or discovers all types the org
produces if only ["*"] is configured). --concurrency (default 2) bounds concurrent file
downloads. Configured sources.eventlogfile.transforms (PII redaction) apply to backfilled rows
too; sampling does not.
Loki rejects samples with timestamps older than its out-of-order window
(reject_old_samples_max_age, default 168h/7d), so there are two strategies for pushing history:
Default (label) strategy — pushes the true event timestamps and tags every backfilled stream
with backfill="true", giving backfilled data its own set of Loki streams so old timestamps don't
collide with the daemon's live streams. Raise Loki's reject_old_samples_max_age (or your Grafana
Cloud stack's out-of-order window) to cover the --since date, or use --ingest-timestamps
instead. The extra cardinality is one bit per stream — avoid stacking more per-run labels on top.
--ingest-timestamps — pushes at ingest time (now) instead, so it always lands inside Loki's
out-of-order window with no config changes needed. The true event time is preserved in structured
metadata (event_time, ISO-8601) so it's still queryable, and no backfill label is added. Use
this when you can't or don't want to touch Loki's ingestion limits.
Resume semantics: progress checkpoints after every fully-pushed file, keyed per
(interval, event_type). Re-running the same command after an interruption picks up from the last
successfully pushed file — already-pushed files are not re-downloaded. If a run does re-push a file
(e.g. a retry landed some but not all of its batches), duplicate rows are harmless in the default
label strategy (byte-identical lines dedup in Loki); in --ingest-timestamps mode a rare
retry-related duplicate won't dedup — acceptable for a one-shot backfill tool.
The command prints a summary on exit (files processed, rows pushed/dropped, bytes pushed, API calls
used, elapsed time) and exits 1 only if Loki pushes fail persistently (10 consecutive failures) —
a transient Salesforce or Loki hiccup is retried automatically.
The container is the primary run target (alongside ECS). The image is slim, runs as a non-root user,
and exposes :8080 (/healthz, /readyz). Metrics are pushed via OTLP, so there is no scrape port.
Every push to main publishes a multi-arch image to GHCR (ghcr.io/rknightion/sf2loki:main, plus
:main-<sha>); releases add semver + :latest. The compose file pulls :latest by default, so
an out-of-the-box deploy tracks releases, not the edge build:
# run the published :latest release image — non-secret values from .env.dev, secrets from ./secrets
docker compose --env-file .env.dev pull
docker compose --env-file .env.dev up -d
# …or build from local source instead (dev iteration):
docker compose -f docker-compose.yml -f docker-compose.build.yml up --build
Set SF2LOKI_TAG=main (documented dev/staging override, e.g. in .env.dev) to track the rolling
edge image instead — it can carry unreleased and breaking changes, so it's opt-in, never the
default. SF2LOKI_TAG=main-<sha> pins a specific edge build.
Upgrades. Releases are semver'd by release-please
from conventional commits — check the CHANGELOG for a feat!:/BREAKING CHANGE:
entry between your current and target version before bumping :latest, the same way you'd check
any other dependency's major-version notes.
docker-compose.yml mounts config.docker.yaml (no
secrets — env-driven via ${VAR} + *_file) at /etc/sf2loki/config.yaml and ./secrets (the
private key + Loki token) read-only at /etc/sf2loki/secrets. The env file is named .env.dev
(that exact filename is what the documented --env-file .env.dev commands expect) — create it from
the values the config interpolates (Salesforce login URL / consumer key / username, Loki URL +
tenant); .env*, *.key, *.crt, and secrets/ are gitignored and .dockerignored so they
can never be baked into a locally built image. Checkpoint state persists to ./state (bind-mounted
at /var/lib/sf2loki) so resume survives container recreation — the container runs as uid 10001,
so make it writable first: mkdir -p state && chmod 770 state && chown 10001 state (770 + chown,
not a permissive 777 — sf2loki doctor's own FAIL hint recommends the same).
Secret file permissions — same uid-10001 rule. The files in ./secrets must be readable by
uid 10001 or the service crash-loops at startup with an actionable "permission denied" error. A
root-owned chmod 0600 key file (the natural way to store one) is exactly the trap: use
chmod 640 plus a group the container user can read, e.g.
chmod 640 secrets/* # or chown the files to uid 10001
Health check target — use /readyz, not /healthz. /healthz is liveness (200 whenever the
process is up, even mid-startup before Salesforce auth); /readyz is readiness (200 only once auth
resolved and the pipeline is running). /readyz also degrades to 503 (with a reason in the body)
when Loki pushes have been failing continuously for longer than
service.unready_after_sink_failing (default 15m; 0 disables) — data is checkpointed and retried,
so this signals "degraded, surface me", not "restart me"; /healthz deliberately stays 200 through
a Loki outage. Docker/ECS collapse container health into a single signal for a standalone
instance, so they should probe /readyz — the Dockerfile HEALTHCHECK already does. For ECS,
set the task definition healthCheck to CMD-SHELL curl -f http://localhost:8080/readyz || exit 1
with a startPeriod (~20s) covering normal startup, and mark the container essential: true so a
fast-fail (e.g. bad Salesforce credentials → process exits) restarts the task. Also set stopTimeout
to cover the shutdown drain — ECS's default (30s) is borderline against service.shutdown_grace
(25s) plus the app's own ~5s closer budget; raise stopTimeout to at least 35s (mirroring the
compose stop_grace_period) if you raise shutdown_grace.
Sharp edge —
/readyzon an HA standby is 503 forever, by design. In the active-passive topology below, the standby never becomes ready, so an ECS task-definitionhealthCheck(which triggers ECS to kill and replace the "unhealthy" task) pointed at/readyzrestart-loops the standby forever and defeats failover./readyzis only ever safe as a target-group health check (controls traffic routing, not task lifecycle) — for the ECS task-levelhealthCheck(and the DockerHEALTHCHECKon an HA replica) use/healthzinstead, exactly the same trap the Kubernetes readinessProbe-vs-livenessProbe warning below describes.
Run exactly one ACTIVE replica (stop-then-start rollout, not overlapping) — the Pub/Sub API delivers events independently per subscriber connection, so a second active instance double-delivers. For automatic failover, run an active-passive pair with the file-lease or Kubernetes-Lease coordinator (next section); see the HA docs for the full HA/replica model.
Loki requirement: structured metadata needs schema v13 + TSDB +
allow_structured_metadata: true(default on Grafana Cloud; must be enabled self-hosted / in Alloy's Loki).
By default sf2loki runs standalone: a single instance is always the leader and streams
continuously. For hands-off failover, run two replicas in an active-passive pair coordinated by
a file lease on shared storage. Exactly one replica (the leader) ingests at a time; the other
stands by and takes over within one lease ttl if the leader dies.
The leader owns a small JSON lease file — {holder, expires_at, epoch} — on storage shared by both
replicas (NFS/EFS/a shared volume), renewing it every renew_interval; the standby polls the lease
and takes over once it has gone stale. Takeover is resolved by atomic tmp+rename with a verification
re-read, so two standbys contending for an expired lease never both win. Failover is protected by
fencing: epoch is a fence token that increments on every winning acquire/takeover, and the
shared-file checkpoint store re-reads the lease fresh at commit time (bypassing its own cache) to
reject a commit whose writer's epoch is behind the lease's current epoch — this catches a stale
leader even before its own local "am I still the leader?" flag has caught up (which only refreshes
once per renew_interval), so a leader that lost the lease during a GC or scheduling pause cannot
race the new leader's checkpoints. The S3/GCS state stores don't need the epoch mechanism: their own
ETag/generation-preconditioned compare-and-swap already rejects a losing writer independently.
Semantics are at-least-once: a mid-batch takeover may re-ingest up to one ttl of events, but
acknowledged data is never lost.
coordinate:
type: file_lease
file_lease:
path: /var/lib/sf2loki/leader.lease # on shared storage, same for both replicas
ttl: 30s # failover time: standby takes over this long after renewals stop
renew_interval: 10s # must be < ttl/2, so one missed renewal never costs leadership
holder_id: "" # blank -> hostname-pid; set explicitly if hostnames aren't unique
Both replicas run the same config; the checkpoint state store must also be shared (the same
volume, or the S3 store above) so the standby resumes exactly where the leader left off. For a
compose pair, mount one shared volume at /var/lib/sf2loki in both services; for VMs, mount the
same NFS/EFS export on both hosts.
Operational caveats:
ttl must
comfortably exceed worst-case inter-host clock skew.ttl. Lower it for faster takeover, at the cost of tighter clock-skew and
renew-latency margins (renew_interval must stay below ttl/2).ttl) around a takeover;
downstream should tolerate duplicate log lines.Observability: sf2loki_leader is 1 on the active leader (and on any standalone instance), 0
on the standby — sum(sf2loki_leader) should always be exactly 1, and the shipped alert pack
fires on anything else (leaderless gap or split-brain). The standby reports 503 standby on
/readyz (a load balancer routes only to the leader) while staying 200 on /healthz.
Sharp edge —
/readyzon the standby is 503 forever, by design. That's correct for a load balancer / target-group health check (routes traffic only to the leader), but it is never safe as a liveness/instance-restart check on an HA replica — a restart-on-/readyz-failure policy restart-loops the standby forever and defeats failover. See the ECS paragraph above and the Kubernetes probes below for the concrete readiness-vs-liveness split.
On Kubernetes, use a coordination.k8s.io/v1 Lease instead of a shared file — no shared volume
needed. Set coordinate.type: k8s_lease and the k8s extra (uv sync --extra k8s from a source
checkout; not in the published image):
coordinate:
type: k8s_lease
k8s_lease:
namespace: sf2loki # namespace holding the Lease (default: default)
name: sf2loki-leader # Lease object name, shared by all replicas
identity: "" # blank -> pod name ($HOSTNAME); set if pod names aren't unique
lease_duration: 30s # failover time: standby takes over this long after renewals stop
renew_interval: 10s # must be < lease_duration/2
# kubeconfig: ~/.kube/config # omit for in-cluster config; set for out-of-cluster dev
The leader renews the Lease's holderIdentity + renewTime; a standby takes over once it is stale.
Optimistic concurrency uses the Lease resourceVersion (a lost update returns HTTP 409, so — unlike
the file lease — no pause-then-verify is needed), and the same fencing gates checkpoint commits on
still holding the lease. In-cluster config is used by default (works with a workload-identity
ServiceAccount); no NTP concern — staleness is judged by each replica's own observedTime
(client-go's leaderelection pattern): a replica tracks, on its own monotonic clock, how long it's
been since it last saw the Lease's resourceVersion change, and only takes over once lease_duration
has elapsed on that clock. The leader's renewTime (a wall-clock value) is written for
observability/kubectl describe but never read back for staleness math, so cross-host wall-clock
skew cannot cause a premature (or delayed) takeover. The state store must still be shared (the
S3/GCS store above) so the standby resumes from the leader's checkpoints.
The pod's ServiceAccount needs get/create/update on leases in the coordinator's namespace:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: sf2loki-lease
namespace: sf2loki
rules:
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "create", "update"]
Bind it to the pod's ServiceAccount with a RoleBinding, and set identity (or rely on the default
$HOSTNAME = pod name) so each replica writes a distinct holderIdentity.
A production Helm chart (Deployment, ServiceAccount + the Role/RoleBinding above, probes, PDB,
resource requests/limits, optional NetworkPolicy/External-Secrets) lives in
deploy/helm/ and is published as an OCI chart to
ghcr.io/rknightion/charts/sf2loki. Set ha.enabled: true + replicaCount: 2 for the pair —
see deploy/helm/README.md and the
Kubernetes deployment docs.
Sharp edge —
readinessProbevslivenessProbe, same trap as ECS above. PointreadinessProbeat/readyz(correct: the Service should only route traffic to the leader) andlivenessProbeat/healthz(the process is alive and its event loop is responsive even while standing by). Never pointlivenessProbeat/readyz— the standby returns503 standbyforever by design, so kubelet restart-loops it continuously and defeats failover entirely. SetterminationGracePeriodSecondsto at leastservice.shutdown_grace(default 25s) plus the app's own ~5s closer budget, with margin — the Helm chart defaults to 40s (terminationGracePeriodSeconds); raise it if you raiseshutdown_grace.
just setup # create the venv from the locked lockfile
just check # full daemon-free gate (ruff, mypy --strict, pytest, drift, Helm, dist)
just gen # regenerate every committed generated artifact
just image # build the container image (requires a Docker daemon)
Python 3.14, uv-managed. Generated proto stubs are committed; CI fails on drift. Tooling: uv,
ruff, mypy --strict, pytest + pytest-asyncio, just, Helm, kubeconform, and Docker for
just ci.
See CONTRIBUTING.md for the contribution workflow and SECURITY.md for reporting vulnerabilities (privately, please).
AGPL-3.0-only — free to use, modify, and self-host; if you run a modified version as a network service, the AGPL requires you to offer its source to those users.