PostHog

Evaluate a PostHog feature flag and use the result as config. Pure net/http on top of httpcore, no third-party SDK.

Schemeposthog://
Modulegithub.com/xavidop/mamori/providers/posthog
Sensitiveno
Watchpoll
AuthPOSTHOG_PROJECT_API_KEY, sent in the request body rather than a header

Install

go get github.com/xavidop/mamori/providers/posthog
import _ "github.com/xavidop/mamori/providers/posthog"

Using the ref

A posthog:// ref points at one feature flag. Without a fragment it resolves the way PostHog’s own SDKs do; a fragment names a specific facet of the evaluated result.

posthog://<flag-key>[#enabled | #variant | #payload]
PartRequiredWhat it means
<flag-key>yesThe feature flag’s key in the PostHog project.
#enablednoThe flag’s enabled state, as true / false.
#variantnoA multivariate flag’s variant key. Empty for a boolean flag.
#payloadnoThe flag’s payload. Empty when the flag has none.

Examples

  • posthog://new-checkout resolves to true / false - pair it with a bool field.
  • posthog://pricing-test resolves to the assigned variant key, e.g. control.
  • posthog://pricing-test#variant does the same, explicitly.
  • posthog://pricing-test#payload resolves to that flag’s payload (often JSON, which you can flatten:"json").
type Config struct {
	NewCheckout bool   `source:"posthog://new-checkout"`
	Pricing     string `source:"posthog://pricing-test" default:"control"`
	Copy        string `source:"posthog://pricing-test#payload"`
}

Any fragment other than those three is rejected with mamori.ErrInvalid before a request is sent, so a typo surfaces at mamori doctor time rather than resolving to an empty string in production.

A distinct id never appears in a ref: PostHog evaluates flags for a distinct id, which identifies the evaluation context rather than the flag, so it is provider-level configuration (WithDistinctID, below). Use a stable id, not a memorable one. A percentage rollout hashes the distinct id, so a fresh random id per process would put two replicas of the same service on opposite sides of the same 50% rollout.

Value mapping

PostHog’s own SDKs return a boolean for a boolean flag and the variant key for a multivariate one. The no-fragment form reproduces that exactly.

Flag shapeFragmentResolved bytes
boolean, enabled(none)true
boolean, disabled(none)false
multivariate, matched(none)the variant key, e.g. control
multivariate, unmatched(none)false
any#enabledtrue / false
any#variantthe variant key; empty for a boolean flag
any#payloadthe payload; empty when the flag has none

A flag counts as multivariate exactly when PostHog sent a variant field, which it does only for a multivariate flag that matched. A disabled flag of either shape therefore resolves to false, never to an empty string. That distinction is why the provider calls POST /flags?v=2: the v=2 envelope carries a per-flag enabled, variant and metadata, while the older flat featureFlags map cannot tell a disabled flag from one that does not exist. #payload unwraps the payload from its JSON-encoded string form, since PostHog documents metadata.payload as a string containing JSON and returning it verbatim would hand you a double-encoded document.

Error classification

Failures are classified through httpcore’s shared status table:

HTTP statusmamori kind
400, 422invalid
401unauthenticated
403permission_denied
404not_found
408, 429rate_limited
anything elseunavailable

Two conditions arrive as HTTP 200 with the flag simply missing from the response, and neither is reported as not-found: in both, the absence says nothing about whether the flag exists, and calling either one not-found would have mamori quietly apply your default: in place of a live flag.

Response bodyReported as
"quotaLimited": ["feature_flags"], the project is over its billing quota and evaluation is pausedrate_limited
"errorsWhileComputingFlags": true with the flag absentunavailable

A quotaLimited array naming some other resource says nothing about flags and is ignored. A flag PostHog computed successfully and did not return is a genuine not_found, so a field’s default: applies as usual.

Watch

PostHog’s flag endpoint pushes nothing, so mamori polls (WithPollInterval + jitter). Each poll is a full evaluation, and one evaluation returns every flag for the distinct id; the provider then selects one by key, so ten refs cost ten evaluations. Value.Version is a content hash of the resolved bytes rather than PostHog’s per-flag metadata.version, which counts edits to the flag’s definition. A flag whose evaluation flips for this distinct id without a definition edit - a percentage rollout the id crosses, an experiment reassignment - is a real change, and hashing the bytes is what catches it.

Configuration

import posthogprov "github.com/xavidop/mamori/providers/posthog"

mamori.WithProvider(posthogprov.New(
	posthogprov.WithProjectAPIKey(os.Getenv("POSTHOG_PROJECT_API_KEY")),
	posthogprov.WithHost("https://eu.i.posthog.com"),
	posthogprov.WithDistinctID("svc-billing"),
	posthogprov.WithGroups(map[string]string{"company": "acme"}),
))
OptionEnvironment variableDefault
WithProjectAPIKeyPOSTHOG_PROJECT_API_KEYnone; a resolve without one fails with mamori.ErrInvalid
WithHostPOSTHOG_HOSThttps://us.i.posthog.com
WithDistinctIDPOSTHOG_DISTINCT_IDmamori
WithGroups / WithPersonProperties / WithGroupProperties-none
WithHTTPClient-30s-timeout client
WithMaxResponseBytes-1 MiB; raise it for a project whose flag payloads exceed it

The credential is the project API key (phc_...), a public client-side token. PostHog’s flag endpoint takes no Authorization header: the key travels in the request body as api_key, and never reaches a URL, an error, a log line, or a Report.

Point the provider at the right region. The default is US Cloud; EU Cloud is https://eu.i.posthog.com, and a self-hosted instance is its own domain. Sending evaluations to the wrong region answers as though every flag were absent, so every field takes its default: and nothing fails.

Verified against an in-process HTTP fake, so the conformance kit runs without a PostHog project. Nobody on this project has credentials, so the request and response shapes follow the vendor’s documentation rather than a live capture, and the //go:build integration tests confirm them once MAMORI_POSTHOG_PROJECT_API_KEY and MAMORI_POSTHOG_FLAG are set.