A collection of service endpoints to support Marketing Operations campaign creation and management.
/livez:GET— checks that the service is alive (liveness probe). Returns200with atext/plainbody ofOK. Does not depend on database availability./readyz:GET— checks that the service is able to take inbound requests (readiness probe), including a PostgreSQL connectivity check. Returns200with atext/plainbody ofOKwhen ready, or503when not ready./metrics:GET— Prometheus metrics in the text exposition format. Served on the same port as the API, and available in no-database mode and during a cold start (a scrape target that only appears once the database is up is exactly backwards). See Metrics below.
These three endpoints are unauthenticated and are excluded from the
generated public API documentation. They are also absent from the Helm
chart's HTTPRoute and Heimdall RuleSet, so they are reachable only
in-cluster (kubelet probes and the Prometheus scraper), never through
the public gateway.
GET /metrics serves the Prometheus text exposition format. The
endpoint takes no configuration — there is no environment variable
to enable it and no separate metrics port. It is independent of the
OTEL_* settings below: OTEL_METRICS_EXPORTER defaults to none, so
wiring these instruments to the OTLP pipeline would leave /metrics
empty in the default deployment.
Service metrics:
| Metric | Type | Labels | What it answers |
|---|---|---|---|
campaign_dispatch_total |
counter | platform, outcome |
Are campaigns actually landing on each ad platform? outcome is one of success, skipped, failure, panic — panic is separate because it is a bug in this service, not an upstream refusal. |
campaign_job_transitions_total |
counter | status |
Dispatch jobs reaching each state (running, succeeded, partial, failed). A growing gap between running and the terminal states means jobs are getting stuck — the terminal transition is recorded only when the status write actually persisted, so a job stuck by a failed write leaves the gap open rather than closing it. |
campaign_upstream_calls_total |
counter | platform, operation, outcome |
Upstream ad-platform API call volume and error rate. |
campaign_upstream_call_duration_seconds |
histogram | platform, operation, outcome |
Upstream ad-platform latency. Timed only after the pre-platform guards pass, so local refusals do not drag the quantiles toward zero. Bucketed on the service's own call budgets (10ms…45s, with edges on the 20s/30s/45s ceilings) rather than the OTel default, whose ms-scale boundaries would collapse every healthy call into one bucket. |
campaign_monitor_unmeasured_rows_total |
counter | platform, class |
Account-monitor campaign rows returned with fetch_failed=true — why the monitor's totals may understate an account. class is one of auth, throttle, upstream_5xx, decode, no_report (a report-backed account with no finished report yet — expected on a first read), other (a failure the client did not classify) or unknown (a client reported a class outside that set, i.e. classification drift). |
campaign_db_pool_* |
gauge / counter | none | Database pool health: acquired, idle, total, max and newly-established connections, plus canceled and empty acquires. Exported only when a pool is actually wired — see below. |
Plus the standard Go runtime and process collectors.
Label cardinality. No metric here carries a campaign id, brief id,
project id, job id, account id or URL as a label value — an unbounded
label creates one retained time series per distinct value and is the
classic way to take a Prometheus server down. platform is mapped
through a closed provider set and anything outside it collapses to
unknown; the outcome and monitor failure-class labels are closed enums; and operation passes a
shape guard that admits short lower-snake tokens and degrades anything
id-shaped or derived to unknown. No metric name, label
or help string carries a credential, DSN or token.
Absent pool metrics are meaningful. When no database is wired (no-DB
mode, or a cold start before the pool opens) the campaign_db_pool_*
series are not exported at all, rather than exported as zeroes. A
zero is a measurement: reporting max_connections=0 for a service
running without a database is indistinguishable from a pool that has
collapsed, and would fire a false exhaustion alert.
Scraping. The chart sets prometheus.io/scrape,
prometheus.io/path and prometheus.io/port on the pod template by
default, so a discovery-based collector picks the pod up with no further
configuration. prometheus.io/port is derived from service.port in
the template rather than hardcoded in values.yaml, so the scrape port
cannot drift from the port the container listens on. A deployment that
overrides podAnnotations replaces the map, so it must re-declare
prometheus.io/scrape and prometheus.io/path to keep being scraped.
Configuration priority: CLI flags > environment variables > defaults.
When any PostgreSQL setting is supplied, the set must be complete or
the process exits non-zero. Fully omitting all database settings is
allowed for unit tests / metadata-only local runs (no-DB mode; /readyz
stays process-ready without a pool). In-cluster they are typically
injected from the ExternalSecret-managed Kubernetes secret
(lfx-v2-campaign-service-secrets in namespace
lfx-v2-campaign-service; keys host, port, username,
password, dbname, engine, credential-encryption-key).
PGHOST(secret keyhost) — PostgreSQL hostnamePGUSER(secret keyusername) — PostgreSQL usernamePGPASSWORD(secret keypassword) — PostgreSQL password (never logged)PGDATABASE(secret keydbname) — PostgreSQL database nameCREDENTIAL_ENCRYPTION_KEY(secret keycredential-encryption-key) — base64-encoded 32-byte AES-256 key used to encrypt ad-platform connection credentials at rest. Required whenever a database is configured. Rollout prerequisite: provision this key onlfx-v2-campaign-service-secrets(via ExternalSecret sync) before deploying the chart revision that references it; otherwise the pod stays inCreateContainerConfigError.
The service composes the DSN in-process from these fields (no
DATABASE_URL env var required).
For laptop runs against the RDS tunnel (or any local Postgres), you
can use this non-production sample (base64 of the 32-byte ASCII
string LFX-campaign-local-dev-aes-256!!):
# !!! WARNING !!!
# This is a *TEST/EXAMPLE* key for local/dev use only.
# NEVER use this in production or shared environments!
# -----------------------------------------------------
# (test/dev/example: base64-encoded 'LFX-campaign-local-dev-aes-256!!')
# Example (use printf so the *plaintext* stays exactly 32 bytes — a trailing
# newline from echo would make it 33 and break AES-256 key length):
# printf '%s' 'LFX-campaign-local-dev-aes-256!!' | base64
export CREDENTIAL_ENCRYPTION_KEY='TEZYLWNhbXBhaWduLWxvY2FsLWRldi1hZXMtMjU2ISE='Again, do not use this value in shared, staging, or production clusters. Generate a real key for those environments, for example:
openssl rand -base64 32-
PGPORT(default5432; secret keyport) — PostgreSQL port -
PGENGINE(default empty) — when set, must bepostgresorpostgresql -
PORT(default8080) — HTTP listen port (CLI flag-p) -
HOST(default*) — bind interface;*means all interfaces (CLI flag-bind) -
DEBUG(unset) — set totrueto enable debug logging (CLI flag-d) -
JWKS_URL— key set every bearer token is verified against (defaults to the in-cluster Heimdall JWKS URL). Set but not an absolute URL fails startup rather than degrading verification. -
JWT_AUDIENCE(defaultlfx-v2-campaign-service) — required audience -
JWT_ISSUER(defaultheimdall) — required issuer -
JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL(default unset) — local development only: when non-empty it disables JWT verification and attributes every request to this principal, logging aWARNon every boot that sets it. Two independent guards keep it out of a deployed environment: the chart refuses to render it, and at runtime the pod fails startup rather than degrading whenever it detects it is running in a cluster — so the failure is a crash-loop, not a silently unauthenticated service. Worth knowing before debugging a rollout that will not come up. The runtime guard is a detection, not a proof: it looks forKUBERNETES_SERVICE_HOSTand the service-account directory, so a manifest applied outside the chart that both sets this variable and suppresses those two signals (an explicit emptyKUBERNETES_SERVICE_HOSTplusautomountServiceAccountToken: false) is not caught. That combination is three deliberate, visible changes rather than one env line, which is the bar the guard is built to — seeinternal/infrastructure/config/config.go. -
NATS_URL— NATS server URL (reserved for messaging; defaults to in-cluster NATS URL) -
EVENT_URL_NAT64_PREFIXES(default unset) — comma-separated RFC 6052 §2.2 network-specific NAT64 prefixes this cluster uses, e.g.2a01:4f8:1::/48. Only/32,/40,/48,/56,/64and/96are valid lengths; a malformed value or a wrong length panics at startup, deliberately, so a typo stops the pod rather than decoding at the wrong offset for its lifetime.Unset is correct for a cluster with no NAT64, and the well-known
64:ff9b::/96is decoded regardless. It cannot be discovered in-process: a network-specific prefix is carved from the operator's own global unicast space and is indistinguishable from any other public prefix. On a cluster that uses one, leaving it undeclared is a live SSRF hole — the translator, not this service, makes the IPv4 connection, so an address encoding169.254.169.254passes every check here and the fetch reaches the cloud metadata endpoint. -
REDDIT_METRICS_ENABLED(default unset, i.e. OFF) — opts a deployment IN to Reddit Ads metrics reads. Only the exact valuetrueenables them; unset or any other value (includingTRUEor a typo) fails closed, andGET .../campaigns/{campaign_id}/metricsanswers 400 "not supported for this campaign's platform" for a Reddit campaign. It also gates the Reddit audience and keyword performance reads (GET /projects/{project_id}/reddit-ads/audienceand.../reddit-ads/keywords), which answer 400 "not supported" while off. Off by default because no request has yet been made against a live Reddit ad account (LFXV2-3282). The request and response shapes now follow Reddit's official public OpenAPI document, but behaviour a schema cannot express — zero-activity rows, the account's attribution window — is still unconfirmed, and a read returning 200 would look authoritative to every consumer. The chart sets it to"false"(charts/lfx-v2-campaign-service/values.yaml); flip it only after the contract is verified against a live Reddit ad account. -
CAMPAIGN_JOB_RETENTION(default4320h, i.e. 180 days) — how long a terminal campaign job (succeeded,partial,failed) is kept before the retention sweeper deletes it. A Go duration string.campaign_jobsis otherwise append-only: nothing else in the service deletes a row, so without the sweeper the table grows with every brief dispatch forever, and the stuck-job recovery sweep pays for that growth on every pass. One bounded, ordered batch is deleted per pass (seeJobRepo.PruneTerminalJobs), so a large backlog drains over several passes rather than in one long transaction. Every replica runs it without leader election, exactly as the stuck-job sweeper does — overlapping passes are harmless because the delete is bounded and idempotent.Only terminal jobs are ever eligible, enumerated as an allow-list rather than as "not queued/running". A queued or running row is never deleted at any age: an old non-terminal row is a stuck job, which is precisely the record needed to investigate a dispatch that never finished. (The recovery sweeper fails those out after 15m, at which point they become terminal and their retention window starts from that transition.) Age is measured on
updated_at— when the job reached its terminal state — so a job created months ago but completed yesterday counts as recent history.The default is deliberately long because these rows are the audit trail of real ad spend. Unset, empty, unparseable (
30 daysand7dare not valid Go durations) and non-positive values all fall back to the 180-day default rather than to a short window, so a typo cannot cause early deletion — the rejected value is logged at startup. -
REDDIT_KEYWORD_TARGETING_WRITES_ENABLED(default unset, i.e. OFF) — opts a deployment IN to BOTH Reddit ad-group targeting writes: removing keywords (POST .../campaigns/{campaign_id}/keyword-targeting/removals) and adding negative keywords toexcluded_keywords(POST .../campaigns/{campaign_id}/negative-keywords). Enabling it enables both. Only the exact valuetrueenables them; anything else fails closed and both endpoints answer 400 for a Reddit campaign. Off by default because Reddit replaces an ad group's targeting as a whole, so the write sends back every dimension it read, and that round trip has not been exercised against a live ad account. The read (GET .../keyword-targeting) is not gated. The chart sets it to"false". -
REDDIT_CONVERSION_EVENT_ENABLED(default unset, i.e. OFF) — opts a deployment IN toredditConfig.conversionEvent(#549), the pixel event a conversions campaign optimizes toward (sent asoptimization_goalon the campaign and the ad group instead of the defaultPURCHASE). Only the exact valuetrueenables it; anything else fails closed, and a create that sets the field is refused with a 400 before any job exists. Off by default because no live create has yet confirmed Reddit accepts a goal other thanPURCHASEon a CONVERSIONS campaign under budget optimization. The chart sets it to"false". -
REDDIT_CUSTOM_AUDIENCES_ENABLED(default unset, i.e. OFF) — opts a deployment IN to Reddit custom audiences (#549): the create inputsredditConfig.customAudienceIds/excludedCustomAudienceIds(the ad group'stargeting.custom_audience_ids/excluded_custom_audience_ids) and the picker readGET /projects/{project_id}/reddit-ads/custom-audiences. Only the exact valuetrueenables them; anything else fails closed: a create setting either list is refused with 400 before any job exists, and the read answers 400. Off by default because both shapes come from Reddit's public v3 OpenAPI document and have not been exercised against a live ad account. The audiences live on the ad account every foundation shares, so the create inputs also needglobal_marketing_opson the project (403 otherwise) and the read is gated on it at the gateway. The chart sets it to"false". -
REDDIT_TARGETING_RESOLUTION_ENABLED(default unset, i.e. OFF) — opts a deployment IN to resolvingredditConfig.subredditsandinterestsbefore create (#549): each community is confirmed withGET /targeting/communitiesand each interest resolved to its id withGET /targeting/interests. An unknown value is a synchronous 400 (the pre-check fails open when the read cannot run) and, at dispatch, a job failure with nothing created (fail closed). Only the exact valuetrueenables it; anything else keeps the create as before. Not role-gated. The chart sets it to"false". -
REDDIT_TARGETING_EXCLUSIONS_ENABLED(default unset, i.e. OFF) — opts a deployment IN toredditConfig.excludedSubredditsandredditConfig.excludedGeoTargets(#549), sent at create time as the ad group'sexcluded_communitiesandexcluded_geolocations. Only the exact valuetrueenables them; anything else fails closed, and a create naming either is refused with a 400 before any job exists. It names no account resource; it keeps a shape no live create has sent off the shared ad account. The chart sets it to"false"(app.environment.REDDIT_TARGETING_EXCLUSIONS_ENABLED.value); enable it per-environment in the ArgoCD overlay once a live create confirms it. -
TWITTER_CREATE_TARGETING_ENABLED(default unset, i.e. OFF) — opts a deployment IN totwitterConfig.geoTargets,languages,keywordsandinterestIds(#564), country, language, broad keyword and interest targeting criteria added to the line item an X create makes. Only the exact valuetrueenables them; anything else fails closed, and a create naming any of them is refused with a 400 before any job exists. It names no account resource; it keeps a shape no live create has sent off the shared ad account. The chart sets it to"false"(app.environment.TWITTER_CREATE_TARGETING_ENABLED.value); enable it per-environment in the ArgoCD overlay once a live create confirms it. -
META_CONVERSIONS_METRICS_ENABLED(default unset, i.e. OFF) — opts a deployment IN to conversions in the Meta metrics read (#550): for a campaign this service created with the conversions objective, the Insights read also requestsactionsand reports the recorded conversion event's action type asconversions(#175 statuses). Only the exact valuetrueenables it; anything else keeps the read as before (conversionsunavailable, noactionsrequested). Read-only, but the event-to-action mapping is unverified live and a wrong one would publish a false zero. The chart sets it to"false". -
TWITTER_CUSTOM_AUDIENCES_ENABLED(default unset, i.e. OFF) — opts a deployment IN to X custom audiences (#564): the create inputstwitterConfig.customAudienceIds/excludedCustomAudienceIds(CUSTOM_AUDIENCEtargeting criteria,EQandNE, on the new line item) and the picker readGET /projects/{project_id}/twitter-ads/custom-audiences. Only the exact valuetrueenables them; anything else fails closed: a create setting either list is refused with 400 before any job exists, and the read answers 400. Off by default because both shapes come from X's published v12 reference and have not been exercised against a live ad account. The audiences live on the ad account every foundation shares, so the create inputs also needglobal_marketing_opson the project (403 otherwise) and the read is gated on it at the gateway. The chart sets it to"false". -
MICROSOFT_AD_EXTENSIONS_ENABLED(default unset, i.e. OFF) — opts a deployment IN tomicrosoftConfig.sitelinksandmicrosoftConfig.callouts(#563), created at create time asSitelinkAdExtensionandCalloutAdExtensionentries associated with the new Search campaign. Only the exact valuetrueenables them; anything else fails closed, and a create naming either is refused with a 400 before any job exists. It names no account resource; it keeps a shape no live create has sent off the shared ad account. The chart sets it to"false"(app.environment.MICROSOFT_AD_EXTENSIONS_ENABLED.value); enable it per-environment in the ArgoCD overlay once a live create confirms it. -
MICROSOFT_AUDIENCES_ENABLED(default unset, i.e. OFF) — opts a deployment IN tomicrosoftConfig.audienceIds,excludedAudienceIdsandaudienceBidAdjustment(#563): remarketing lists checked withGetAudiencesByIdsbefore anything is created, then attached to the new ad group as observed (bid-only) and excluded audience criteria. Only the exact valuetrueenables them; anything else fails closed, and a create naming any of the three is refused with a 400 before any job exists. The lists live on the account every foundation shares, so they also needglobal_marketing_opson the project (403 otherwise). The chart sets it to"false"(app.environment.MICROSOFT_AUDIENCES_ENABLED.value); enable it per-environment in the ArgoCD overlay once a live create confirms it. -
MICROSOFT_CREATE_TARGETING_ENABLED(default unset, i.e. OFF) — opts a deployment IN tomicrosoftConfig.excludedGeoTargets,proximityTargetsandlanguages(#563): excluded countries (negative location criteria) and radius targets attached to the new campaign before its ad group, and the campaign's ad languages. Only the exact valuetrueenables them; anything else fails closed, and a create naming any of the three is refused with a 400 before any job exists. They name no account resource; the flag keeps a shape no live create has sent off the shared account. The chart sets it to"false"(app.environment.MICROSOFT_CREATE_TARGETING_ENABLED.value). -
GOOGLE_ADS_DEMAND_GEN_AUDIENCE_ENABLED(default unset, i.e. OFF) — opts a deployment IN togoogleAdsConfig.demandGenAudience, one existing Google Ads Audience on a Demand Gen ad group (#459). Only the exact valuetrueenables it; anything else fails closed, and a create naming one is refused before the budget. It is a per-deployment opt-in: the Audience is checked against the connected Google Ads account (same customer id). That account is shared by every foundation, andcampaign_managerincludes foundation executive directors, so create-campaigns also requiresglobal_marketing_opson the project for any Demand Gen audience: 403 without it, 503 when the check cannot be made (it asks project-service and fga-sync over NATS). The same flag gates eachdemandGenAdGroups[].audience(#460), the same input on a named ad group,demandGenDemographics.audienceSegments(#498) anddemandGenDemographics.customAudiences(#516), existing user lists and custom audiences put in the demographic Audience, and the same two lists in anydemandGenAdGroups[].demographics(#518): off, a create naming any is refused before the budget, and each needsglobal_marketing_opslike the audiences above. The rest of Demand Gen, including ad groups without an audience, is not gated. The chart sets it to"false"(app.environment.GOOGLE_ADS_DEMAND_GEN_AUDIENCE_ENABLED.value); enable both per-environment by setting it to"true"in the ArgoCD overlay. -
GOOGLE_ADS_DISPLAY_AUDIENCES_ENABLED(default unset, i.e. OFF) — opts a deployment IN to the Display audience lists that name an account-owned segment (#462):googleAdsConfig.audienceSegmentson thedisplaychannel,customAudiencesandcustomAffinities, and since #475 the user-list exclusionsexcludedAudienceSegmentsand, on eachdisplayAdGroupsentry, itsaudienceSegments,customAudiences,customAffinitiesandexcludedAudienceSegments. Only the exact valuetrueenables them; anything else fails closed, and a create naming one is refused before the budget, naming the fields to omit. LikeGOOGLE_ADS_DEMAND_GEN_AUDIENCE_ENABLED, it is a per-deployment opt-in, each segment is checked against the connected Google Ads account, and create-campaigns requiresglobal_marketing_opson the project for any of these fields (403 without it, 503 when the check cannot be made).userInterests(Google's own categories, at either level),excludedPlacements(public domains), Search's observation-onlyaudienceSegmentsanddisplayAdGroupsentries without those lists are not behind the flag. Search'saudienceSegments(campaign level and eachadGroupsentry) still name the shared account's user lists, so they too requireglobal_marketing_ops. The chart sets it to"false"(app.environment.GOOGLE_ADS_DISPLAY_AUDIENCES_ENABLED.value); enable it per-environment by setting it to"true"in the ArgoCD overlay. -
META_CALL_TO_ACTION_ENABLEDandMETA_CONVERSION_EVENT_ENABLED(default unset, i.e. OFF) — opt a deployment IN tometaConfig.callToAction(the button on every Meta ad:SIGN_UP,BUY_TICKETS, … instead ofLEARN_MORE) andmetaConfig.conversionEvent(the conversions objective'scustom_event_type:COMPLETE_REGISTRATION,LEAD, … instead ofPURCHASE), #550. Only the exact valuetrueenables each; anything else fails closed, and a create carrying the field is refused with a 400 before any job exists. The chart sets both to"false"; enable them per-environment in the ArgoCD overlay. -
META_AUDIENCE_TARGETING_ENABLED(default unset, i.e. OFF) — opts a deployment IN tometaConfig.targeting(#550):ageMin/ageMax,genders,customAudiences,excludedCustomAudiencesandadvantageAudienceon the Meta ad set. Only the exact valuetrueenables it; anything else fails closed, and a create carrying atargetingblock is refused with a 400 before any job exists. Each custom audience is read and must belong to the connected Meta ad account before anything is created, and create-campaigns requiresglobal_marketing_opson the project for the two audience lists (403 without it, 503 when the check cannot be made). The chart sets it to"false"(app.environment.META_AUDIENCE_TARGETING_ENABLED.value); enable it per-environment by setting it to"true"in the ArgoCD overlay. -
GOOGLE_ADS_PMAX_AUDIENCE_SIGNAL_ENABLED(default unset, i.e. OFF) — opts a deployment IN togoogleAdsConfig.performanceMaxAudienceSignal, one existing Google Ads Audience attached to a Performance Max asset group as a signal (#461). Only the exact valuetrueenables it; anything else fails closed, and a create naming one is refused before the budget. LikeGOOGLE_ADS_DEMAND_GEN_AUDIENCE_ENABLED, it is a per-deployment opt-in, the Audience is checked against the connected Google Ads account, and create-campaigns requiresglobal_marketing_opson the project for it.performanceMaxSearchThemes(free text) and the Performance Max sitelinks, callouts and structured snippets are not gated. The chart sets it to"false"(app.environment.GOOGLE_ADS_PMAX_AUDIENCE_SIGNAL_ENABLED.value); enable it per-environment by setting it to"true"in the ArgoCD overlay. -
GOOGLE_ADS_SHARED_NEGATIVE_LISTS_ENABLED(default unset, i.e. OFF) — opts a deployment IN togoogleAdsConfig.sharedNegativeKeywordLists, existing shared negative keyword lists attached to a Search campaign (#477). Only the exact valuetrueenables it; anything else fails closed, and a create naming one, on any channel, is refused before the budget. LikeGOOGLE_ADS_DEMAND_GEN_AUDIENCE_ENABLED, it is a per-deployment opt-in, each shared list is checked against the connected Google Ads account, and create-campaigns requiresglobal_marketing_opson the project for any.searchAssets(the caller's own images, logo and business name) is not gated. The chart sets it to"false"(app.environment.GOOGLE_ADS_SHARED_NEGATIVE_LISTS_ENABLED.value); enable it per-environment by setting it to"true"in the ArgoCD overlay. -
GOOGLE_ADS_DEMOGRAPHIC_TARGETING_ENABLED(default unset, i.e. OFF) — opts a deployment IN to the Google Ads demographic inputs not yet exercised by a live (persisted) create:googleAdsConfig.observedDemographics(#497),googleAdsConfig.displayDemographics(#499),googleAdsConfig.demandGenDemographics(#498; its create body was accepted by a validateOnly probe on 2026-11-05, but a persisted create and the read-back its reuse relies on are not yet verified) and eachdemandGenAdGroups[].demographics(#518, the same object building that ad group its own Audience). Only the exact valuetrueenables it; anything else fails closed, and a create naming one, on any channel, is refused before the budget. It names no account resource; it keeps an unverified shape off the shared account. The chart sets it to"false"(app.environment.GOOGLE_ADS_DEMOGRAPHIC_TARGETING_ENABLED.value); enable it per-environment in the ArgoCD overlay once the probe passes. -
LINKEDIN_ADS_CONVERSIONS_ENABLED(default unset, i.e. OFF) — opts a deployment IN tolinkedInConfig.conversions(#552), conversion rule ids associated with the campaign through Campaign Conversions once it exists. Only the exact valuetrueenables it; anything else fails closed, and a create naming it is refused with a 400 before a job exists. The ids also requireglobal_marketing_ops. The chart sets it to"false"(app.environment.LINKEDIN_ADS_CONVERSIONS_ENABLED.value); enable it per-environment in the ArgoCD overlay once the probe passes. -
SURVEY_SEQUENCE_WORKFLOW_ID(default unset) — the HubSpot workflowPOST /projects/{project_id}/send-workflowscopies when a request names nosource_workflow_url. Unset or empty selects the in-code default1841232183, the LF Research survey-promo workflow, which exists only in the LF portal. The chart sets it to"". -
MICROSOFT_METRICS_ENABLED(default unset, i.e. OFF) — opts a deployment IN to Microsoft Advertising (Bing Ads) metrics reads. Only the exact valuetrueenables them; unset or any other value (includingTRUEor a typo) fails closed, andGET .../campaigns/{campaign_id}/metricsanswers 400 "not supported for this campaign's platform" for a Microsoft campaign. Off by default because the v13 Reporting contract was implemented from Microsoft's published documentation and has NOT been exercised against a live Microsoft Advertising account. Microsoft's pipeline is also unlike every other platform's — an asynchronous submit/poll/download returning a zipped CSV rather than one JSON GET — so there is more surface to be wrong about. The chart sets it to"false"(charts/lfx-v2-campaign-service/values.yaml); flip it only after the contract is verified against a live Microsoft ad account. -
MICROSOFT_AD_COPY_ENABLED(default unset, i.e. OFF) — opts a deployment IN tomicrosoftConfig.headlines,descriptions,path1andpath2(#563), the caller copy of the responsive search ad sent instead of the auto-composed copy. Only the exact valuetrueenables them; anything else fails closed, and a create naming any of them is refused with a 400 before any job exists. It names no account resource; it keeps a shape no live create has sent off the shared ad account. The chart sets it to"false"(app.environment.MICROSOFT_AD_COPY_ENABLED.value); enable it per-environment in the ArgoCD overlay once a live create confirms it. -
MICROSOFT_CREATE_NEGATIVE_KEYWORDS_ENABLED(default unset, i.e. OFF) — opts a deployment IN tomicrosoftConfig.negativeKeywords(#563), campaign-level negative keywords attached right after a Microsoft Advertising campaign is created, through the sameAddNegativeKeywordsToEntitiescall theadd-negative-keywordslever makes. Only the exact valuetrueenables it; anything else fails closed, and a create naming a non-empty list is refused with a 400 before any job exists. It names no account resource; it keeps a create-path call no live create has made off the shared ad account. The chart sets it to"false"(app.environment.MICROSOFT_CREATE_NEGATIVE_KEYWORDS_ENABLED.value); enable it per-environment in the ArgoCD overlay once a live create confirms it. -
TWITTER_METRICS_ENABLED(default unset, i.e. OFF) — opts a deployment IN to BOTH X (Twitter) Ads features built on X's asynchronous stats-jobs API: the account monitor (GET /projects/{project_id}/connection-twitter-ads/account-monitor) and the audience read (GET /projects/{project_id}/twitter-ads/audience). Only the exact valuetrueenables them; unset or any other value fails closed and both endpoints answer 400 "not supported". Off by default because the stats-jobs contract (active_entities, job create, job status, a downloaded gzip results file, segmentation) was implemented from X's published documentation and has NOT been exercised against a live X Ads account. X's per-campaign metrics read (a synchronous endpoint) is unaffected. Their per-account limits are per process, so at runtime only the pod holding a per-account Postgres advisory lease runs X stats jobs — any other pod answers the audience read 503 and skips monitor report submissions. The chart renders the flag at any replica count and only warns in itsNOTES.txtabove one (#381) — ArgoCD never shows that warning, and at three replicas most X audience reads land on a non-owner pod and 503, so run one replica while the flag is on. The chart sets it to"false"(charts/lfx-v2-campaign-service/values.yaml); flip it only after the contract is verified against a live X ad account. -
LFX_FORCE_SYSTEM_ADS_ACCOUNT(default unset, i.e. OFF) — makes the LF-owned system account (system:linuxfoundation) the PRIMARY credential source when a paid-ads campaign is CREATED, so every new campaign authenticates as the marketing-ops account regardless of any per-project connection (the system row is otherwise only a fallback for projects that have connected no account of their own). Only the exact valuetrueenables it; unset or any other value (includingTRUEor a typo) leaves the default resolution.It governs creation and account discovery only. Pausing, resuming and reading metrics for a campaign that ALREADY EXISTS keep using the account it was created under, so campaigns created before the flag was turned on stay controllable while it is on — forcing them onto the system account would trip each adapter's account-provenance guard and leave a live, spending campaign the service cannot pause.
While it is on, an ad account id cannot be SAVED onto a project's connection (400); discovery is resolving the LF credential, so the ids it returns are LF-owned and persisting one would outlive the flag. Clearing a selection stays allowed, as is re-sending the id already stored — the guard refuses a CHANGED id, not a present one. This is what keeps the rollout reversible by flipping the flag back.
One consequence to plan around:
linkedin-adsandreddit-adsdeclareaccount_idas REQUIRED on their create payloads, so a create body cannot omit it and those two providers cannot be connected at all while the flag is on.google-ads,meta-ads,twitter-ads(as of LFXV2-3319) andmicrosoft-ads(as of issue #181) are credentials-first and can still be created. (X'sfunding_instrument_idis still required; credentials-first defers the ACCOUNT choice only.) The per-endpoint status contract is indocs/api-catalog.mdunder Platform Connections.HubSpot/email is never forced (the path gates on the paid-ads channel kind). A missing or unusable system row fails the dispatch closed rather than falling through to the project connection; a MISSING one is reported as an operator-owned configuration fault (500), not as "connect your project" (404). It shares the exact-match
trueparse withREDDIT_METRICS_ENABLEDbut not its lifecycle: this flag is read ONCE, when the credentials source is constructed, so changing it requires a restart, whereasREDDIT_METRICS_ENABLEDis re-read from the environment on every metrics read. Enable per-environment via the ArgoCD overlay. Seespecs/006-force-system-ads-account.
Read-only warehouse access, used ONLY to resolve an event's past editions when
building an audience (LFXV2-2774). These are OPTIONAL as a GROUP: unless
SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER and SNOWFLAKE_PRIVATE_KEY are ALL set,
the warehouse is treated as unconfigured and audience building still works —
it produces a country-only audience and records the narrower scope in the
audience's inclusion summary. A partial or unusable configuration degrades the
same way rather than failing startup, because past editions ENRICH an audience
rather than making it correct.
SNOWFLAKE_ACCOUNT— account identifierSNOWFLAKE_USER— user for key-pair authSNOWFLAKE_PRIVATE_KEY— PEM private key for key-pair authSNOWFLAKE_WAREHOUSE— optional warehouse; the account default is used when omittedSNOWFLAKE_ROLE— optional role; the user's default is used when omitted
The LF LiteLLM proxy, used ONLY to generate email copy — subject, preheader,
body and CTA — via the POST /projects/{id}/briefs/{id}/email-copy endpoint
(LFXV2-2775). Optional as a GROUP: unless BOTH AI_PROXY_URL and AI_API_KEY
are set, the endpoint returns 503 (service unavailable). The pod starts successfully
with or without these values configured.
AI_PROXY_URL— LiteLLM proxy base URL;/chat/completionsis appendedAI_API_KEY— the proxy's key, not a Bedrock or Anthropic credential (the proxy holds those), so it cannot be replayed against a model provider directlyAI_MODEL— optional, not a secret; the model id the proxy routes on. Empty selectsllm.DefaultModel.
In-cluster the two secrets come from the same ExternalSecret-managed secret as
the rest; AI_MODEL is a plain chart value.
Lets a Reddit campaign use an image stored through upload-creative-asset
(redditConfig.imageAssetId). Reddit only accepts a public image URL that it
fetches, so the service serves the stored bytes at a short-lived HMAC-signed URL
(GET|HEAD /_campaigns/creative-assets/{project}/{brief}/{asset}?exp=…&sig=…,
anonymous by design) and hands that URL to Reddit (LFXV2-2665). Optional as a
PAIR: with either of the first two unset the feature is OFF — the route returns
404 and a Reddit config naming imageAssetId is refused with a 400. A value that
is set but unusable stops the pod at startup — each supplied value is validated even
while the other half is still missing.
CREATIVE_ASSET_URL_SIGNING_KEY— secret; standard base64 of at least 32 random bytes (openssl rand -base64 32). In-cluster it comes fromcreative-asset-url-signing-keyon the ExternalSecret-managed secret.CREATIVE_ASSET_PUBLIC_BASE_URL— thehttpsorigin of the public gateway Reddit can reach (e.g.https://lfx-api.<domain>); no path, query or userinfo.CREATIVE_ASSET_URL_TTL— optional Go duration, default10m, allowed5m..60m(the floor covers the promoted-post request plus Reddit's fetch and clock skew).
See docs/knowledge/architecture/signed-creative-asset-urls.md for the security model.
Creative-asset uploads draw on ONE per-pod memory budget: each upload's wire side (body buffer, base64 string, decoded bytes; charged before the body is read) and its decoded pixel buffer (charged before the image is decoded) come out of the same budget, so concurrent uploads cannot exceed it in total.
UPLOAD_MEMORY_BUDGET_BYTES— optional; a byte count or a binary quantity (256Mi,1Gi). Unset (the default), the budget is 40% of the container memory limit read from the cgroup (v2memory.max, else v1memory.limit_in_bytes), or 40% of 512Mi (~205Mi) when no limit is readable. It is never below 192Mi, the cost of one maximum-size upload (a smaller value is raised and logged). A malformed value stops the service at startup.
The wire side may use the budget only up to its size less 80Mi, which is kept so an upload that
has already been admitted can always get its pixel charge. When the budget is exhausted an upload
waits up to 250ms at each of the two stages (so at most ~500ms), then gets 503 with
Retry-After: 1.
The budget's size and current use are exported at /metrics as
campaign_upload_memory_budget_bytes and campaign_upload_memory_budget_in_use_bytes.
OpenTelemetry is opt-in. Exporters default to none (no collector
required for local runs).
OTEL_SERVICE_NAME(defaultlfx-v2-campaign-service)OTEL_SERVICE_VERSION(default: build version)OTEL_EXPORTER_OTLP_PROTOCOL(defaultgrpc) —grpcorhttpOTEL_EXPORTER_OTLP_ENDPOINT— collector endpointOTEL_EXPORTER_OTLP_INSECURE(defaultfalse) — insecure whentrueOTEL_TRACES_EXPORTER(defaultnone) —otlpornoneOTEL_METRICS_EXPORTER(defaultnone) —otlpornoneOTEL_LOGS_EXPORTER(defaultnone) —otlpornoneOTEL_PROPAGATORS(defaulttracecontext,baggage) — comma-separated;jaegersupportedOTEL_TRACES_SAMPLER(defaultparentbased_traceidratiowhen unset) — sampler type (always_on,always_off,traceidratio,parentbased_*, …)OTEL_TRACES_SAMPLER_ARG(default1.0) — sampler argument; for ratio-based samplers, a value in[0.0, 1.0]
In lfx-v2-dev, Postgres is RDS. The cluster exposes it as an
ExternalName Service (lfx/rds-postgres). A plain
kubectl port-forward svc/rds-postgres … does not work
(ExternalName has no endpoints). Use a short-lived jump pod with
socat, then port-forward to that pod.
Credentials live in secret
lfx-v2-campaign-service-secrets (namespace
lfx-v2-campaign-service), keys: host, port, username,
password, dbname, engine.
# 0) Point kubectl at development (example path; adjust if needed)
export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/lfx-v2-dev}"
# 1) Confirm the secret exists (do not print the password)
kubectl -n lfx-v2-campaign-service get secret \
lfx-v2-campaign-service-secrets
# 2) Read the RDS hostname from the secret (safe: host only)
RDS_HOST="$(kubectl -n lfx-v2-campaign-service get secret \
lfx-v2-campaign-service-secrets \
-o jsonpath='{.data.host}' | base64 -d)"
RDS_PORT="$(kubectl -n lfx-v2-campaign-service get secret \
lfx-v2-campaign-service-secrets \
-o jsonpath='{.data.port}' | base64 -d)"
RDS_PORT="${RDS_PORT:-5432}"
# Both must be non-empty before creating the jump pod
if [ -z "$RDS_HOST" ] || [ -z "$RDS_PORT" ]; then
echo "RDS_HOST/RDS_PORT empty — refuse to create broken tunnel" >&2
exit 1
fi
echo "tunnel target ${RDS_HOST}:${RDS_PORT}"
# 3) Start a jump pod that listens on 5432 and dials RDS.
# Do NOT use --command (replaces the socat entrypoint →
# "tcp-listen:…: executable file not found").
# Do NOT use -it (Gatekeeper blocks interactive TTYs).
# Do NOT use --rm until you are done (you need the pod alive
# for port-forward).
# Delete any prior failed pod first if needed:
# kubectl -n lfx-v2-campaign-service delete pod pg-tunnel \
# --ignore-not-found
kubectl -n lfx-v2-campaign-service delete pod pg-tunnel \
--ignore-not-found
kubectl -n lfx-v2-campaign-service run pg-tunnel \
--restart=Never --image=alpine/socat -- \
tcp-listen:5432,fork,reuseaddr \
"tcp-connect:${RDS_HOST}:${RDS_PORT}"
kubectl -n lfx-v2-campaign-service wait --for=condition=Ready \
pod/pg-tunnel --timeout=60s
# Confirm args include the real host (not tcp-connect::)
kubectl -n lfx-v2-campaign-service get pod pg-tunnel \
-o jsonpath='{.spec.containers[0].args}{"\n"}'In a second terminal (leave this running — stopping it causes
connection refused on /readyz):
export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/lfx-v2-dev}"
# 4) Forward laptop:5432 -> jump pod:5432
kubectl -n lfx-v2-campaign-service port-forward \
pod/pg-tunnel 5432:5432
# Expect:
# Forwarding from 127.0.0.1:5432 -> 5432
# Later, when the service pings, you may also see:
# Handling connection for 5432In a third terminal — build, load creds, run:
export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/lfx-v2-dev}"
# Always use the tunnel endpoint on the laptop, not the RDS FQDN.
# If you export PGHOST from the secret's `host` key, readyz will
# time out (laptop cannot reach private RDS directly).
export PGHOST=127.0.0.1
export PGPORT=5432
export PGUSER="$(kubectl -n lfx-v2-campaign-service get secret \
lfx-v2-campaign-service-secrets \
-o jsonpath='{.data.username}' | base64 -d)"
export PGPASSWORD="$(kubectl -n lfx-v2-campaign-service get secret \
lfx-v2-campaign-service-secrets \
-o jsonpath='{.data.password}' | base64 -d)"
export PGDATABASE="$(kubectl -n lfx-v2-campaign-service get secret \
lfx-v2-campaign-service-secrets \
-o jsonpath='{.data.dbname}' | base64 -d)"
# **Note**: This is a Local-dev sample only (see "Local / test sample key" above).
# NEVER use this in production or shared environments!
export CREDENTIAL_ENCRYPTION_KEY='TEZYLWNhbXBhaWduLWxvY2FsLWRldi1hZXMtMjU2ISE='
# Sanity-check before starting (must be 127.0.0.1, not the RDS FQDN)
echo "PGHOST=$PGHOST PGPORT=$PGPORT PGDATABASE=$PGDATABASE"
# optional: confirm the tunnel accepts TCP
nc -z 127.0.0.1 5432 && echo "tunnel port open"
make build
make run
# On startup, the log line "dependency container initialized" must
# show database=127.0.0.1:5432/<dbname> — if it shows the RDS
# hostname, stop and fix PGHOST.Smoke-check readiness (expects 200 / OK while the tunnel is up):
curl -sS -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/readyz
# expect body OK and HTTP 200
curl -sS -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/livez
# expect body OK and HTTP 200 (even if readyz would be 503)Cleanup: stop the port-forward (Ctrl-C), then delete the jump pod:
kubectl -n lfx-v2-campaign-service delete pod pg-tunnel \
--ignore-not-foundtcp-listen:…: executable file not found— used--command. Recreate withrun … -- args(no--command).- Gatekeeper TTY warning / blocked — used
-it. Omit-it. - Pod args show
tcp-connect::—RDS_HOST/RDS_PORTwere empty at create. Re-export from secret, delete pod, recreate. - Startup log shows RDS FQDN as database —
PGHOSTwas taken from secrethost. Useexport PGHOST=127.0.0.1and restart. connection refusedon 127.0.0.1:5432 — port-forward not running. Restart Terminal 2.context deadline exceededwithPGHOST=127.0.0.1— jump pod dialing wrong/empty target, or tunnel stalled. Check pod args; recreate tunnel.
- VPN / direct RDS access — if your laptop can reach the RDS
FQDN, skip the jump pod; set
PGHOST/PGPORTfrom the secrethost/portkeys andmake run. - Local Docker / Homebrew Postgres — no tunnel; use
PGHOST=127.0.0.1with local credentials. - CloudNativePG ClusterIP Service —
kubectl port-forward svc/<cnpg-rw-service> 5432:5432works without a jump pod.
See also specs/002-db-conn-check/quickstart.md for readiness /
liveness validation scenarios.
Prefer make run (above) for day-to-day Go iteration. To exercise the
Helm chart — probes, secret refs, and env wiring — build an image and
install with the local values override.
make helm-install-local installs into namespace lfx (see
HELM_NAMESPACE in the Makefile). The chart still requires secret
lfx-v2-campaign-service-secrets (keys: host, port, username,
password, dbname) in that same namespace for the required PG*
env refs. Without it the pod stays in CreateContainerConfigError.
Pick one of these before installing. Set HELM_NAMESPACE once and
pass it to the final make helm-install-local (Makefile assigns
HELM_NAMESPACE=lfx with =, so an env prefix does not override).
# Default local release namespace (Option A / C)
HELM_NS=lfx
# Option A — copy the secret from lfx-v2-dev into a local cluster.
# Use distinct source and destination contexts (adjust names to match
# `kubectl config get-contexts`). Rebuild a clean Secret so server-
# managed fields (uid, resourceVersion, …) are not re-applied.
SRC_CONTEXT="${SRC_CONTEXT:-lfx-v2-dev}"
DST_CONTEXT="${DST_CONTEXT:-kind-kind}"
kubectl --context="$SRC_CONTEXT" get secret \
lfx-v2-campaign-service-secrets \
-n lfx-v2-campaign-service -o json \
| jq '{
apiVersion: .apiVersion,
kind: .kind,
type: .type,
metadata: { name: .metadata.name, namespace: "lfx" },
data: .data
}' \
| kubectl --context="$DST_CONTEXT" apply -f -
# Option B — install into the namespace that already has the secret
HELM_NS=lfx-v2-campaign-service
# Option C — point PG* at a local database in values.local.yaml
# (override PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE with `value:`
# entries instead of secretKeyRef; see values.yaml for the keys)Then:
# 1) Copy the example override (gitignored once renamed)
cp charts/lfx-v2-campaign-service/values.local.example.yaml \
charts/lfx-v2-campaign-service/values.local.yaml
# Edit values.local.yaml as needed (encryption key sample is included).
# 2) Build the image (pullPolicy: Never in the local values file)
make docker-build
# 3) Load the image into your local cluster if needed (kind example):
# kind load docker-image \
# ghcr.io/linuxfoundation/lfx-v2-campaign-service/campaign-service:latest
# 4) Install / upgrade the chart (uses HELM_NS from above)
make helm-install-local HELM_NAMESPACE="$HELM_NS"values.local.example.yaml documents the copy path and
make helm-install-local target. Uninstall with make helm-uninstall.
Common workflow targets (see the Makefile for the full list):
make all # clean → apigen → fmt → lint → test → build
make clean # remove bin/ and coverage.out
make apigen # generate API code from design/ (required before first build)
make fmt # format Go code (gofmt + simplify)
make check-fmt # verify formatting (used in CI)
make lint # run golangci-lint
make test # run tests with race detector and coverage
make build # build a local binary
make build-release # build a static release binary for Linux
make run # build and run locally (needs PG* env; see above)Before flipping REDDIT_METRICS_ENABLED, MICROSOFT_METRICS_ENABLED or
TWITTER_METRICS_ENABLED, run the gated read against a live account with the
same database and key settings as make run:
go run ./cmd/verify-metrics -provider reddit-ads -project <slug|lf-system> \
-campaign <platform campaign id> -window last_7_days [-raw]It resolves the connection and calls the dispatcher's ReadMetrics exactly as
the service does, forces the flag on in its own process only, is read-only
(read-only DB session; non-read upstream requests refused before sending) and
redacts every secret. google-ads is refused. See
docs/knowledge/code/cmd-verify-metrics.md.
CI runs MegaLinter on pull requests and merge-queue entries
(.github/workflows/mega-linter.yaml, Go flavor v9.1.0). Config lives in
.mega-linter.yml. To reproduce
locally with Docker or OrbStack:
docker pull oxsecurity/megalinter-go:v9.1.0
docker run --rm \
-e DEFAULT_WORKSPACE=/tmp/lint \
-e GOTOOLCHAIN=auto \
-e MEGALINTER_CONFIG=.mega-linter.yml \
-v "$PWD:/tmp/lint:rw" \
oxsecurity/megalinter-go:v9.1.0Reports are written under megalinter-reports/ (gitignored). The first
pull is large; a full run often takes several minutes.
For a faster secrets-only check matching the CI gitleaks step:
gitleaks detect --source . --config .gitleaks.tomlNote: if this checkout is a git worktree, the containerized git_diff
linter may fail because the worktree .git path is not visible inside
the container. That does not affect GitHub Actions (normal
actions/checkout).
docs/knowledge/ is an Open Knowledge Format (OKF)
bundle — plain markdown with YAML frontmatter — that gives humans and AI
agents a structured map of this repo's architecture, Kubernetes resources,
Go packages, and feature specs. Start at
docs/knowledge/index.md.
When to update it: after merging a feature PR, changing an API endpoint, adding or modifying a Helm resource, or changing a package's responsibility.
How to update it:
- Edit the relevant existing concept file under
docs/knowledge/**, or add a new one with OKF frontmatter (type,title,description) if no existing concept covers the change. Do not regenerate withgo run ./cmd/okfgen— that tool bootstraps new subtrees and will overwrite hand-edited concept files. - Add or update the concept's
* [Title](url) - descriptionbullet in the relevantindex.md. - Add a new file
docs/knowledge/log/YYYY-MM-DD-<slug>.md(slug = ticket + short description), with a first H1 dated to match the filename followed by**Update** — <what changed and why>.One file per entry — this keeps concurrent PRs from ever editing the same log file.
Validate before pushing:
go run ./cmd/okfvalidate ./docs/knowledgeThis is the same check .github/workflows/validate-okf.yml runs in CI.
Agents are expected to do this bookkeeping automatically (see CLAUDE.md);
developers making manual changes should follow the same convention.