posthog

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 10 Imported by: 0

README

posthog

PostHog behind your origin, in one call. This is the packaged version of the PostHog recipe from gofastr's analytics-recipes doc: the relay route table, the page bootstrap, and the identity endpoint, composed instead of re-derived.

p := posthog.New(posthog.Config{
	Key:    "phc_...", // the public project API key; required
	Region: "us",      // "us" (default) | "eu"
})
app.RegisterPlugin(p)  // relay routes + {mount}/boot.js + {mount}/whoami
host.RegisterExternalScript(p.ScriptURL())
// or, the same thing: p.Attach(host)

Unlike every other package in this repo, this is not a sandboxed iframe plugin. posthog-js instruments the whole host document — page- views, rage clicks, session replay — so it runs in the host page by design and cannot be fenced. What stays first-party is the wire: the visitor's browser only ever talks to your origin, the strict default CSP (default-src 'self') needs no exceptions, and no third-party cookie ever lands on your domain. The isolation contract here is battery/relay's, not the plugin cage's.

What traffic flows where

With the default mount (/__gofastr/t):

Browser requests Served by Goes to (us / eu)
{mount}/boot.js your app (rendered bootstrap, ETag + immutable on ?v=) —
{mount}/whoami your app (identity from the session) —
{mount}/ph-assets/** relay, CacheOK us-assets.i.posthog.com / eu-assets.i.posthog.com
{mount}/ph/** relay (beacons: /e, /s, /i/v0/e, /flags, /decide, /batch) us.i.posthog.com / eu.i.posthog.com

ui_host in the bootstrap stays the real region UI (us.posthog.com / eu.posthog.com) and is never relayed: the toolbar and session-replay player load UI assets from that host directly, and pointing it at the relay breaks them. The SDK itself is loaded through the relay ({mount}/ph-assets/static/array.js).

The bootstrap: init with capture_pageview:false + capture_pageleave:false, identity resolved from {mount}/whoami ({"id":...} or {"id":null}) with a generation guard, transitions anon→A identify, A→anon reset, A→B reset then identify, the initial $pageview fired after identity, and one $pageview per gofastr:navigate (never beforenavigate — it is cancelable, and counting there records visits that never happened).

Config

Field Default Meaning
Key — (required) Project API key, phc_.... Panics on the secret shapes: phx_ (personal) and sk_ (server) would ship to every visitor in the served bootstrap.
Region "us" "us" or "eu". Picks both relay upstreams and the ui_host. Anything else panics at New.
SelfHost Point every route (assets, ingestion, ui_host) at one self-hosted PostHog origin, e.g. the docker hobby deploy at http://localhost:8000. Mutually exclusive with Region.
Path relay.DefaultPath (/__gofastr/t) Relay mount; every route this package serves lives under it. Validated by relay.New.
SessionReplay false Raises the ingestion route's body cap from the relay's 8 MiB default to 64 MiB, what replay uploads reach. Read it as an egress number: every accepted byte is billed to your bandwidth.
RespectDNT false Visitors whose browser reports Do-Not-Track get nothing: no SDK script, no beacons.
PersonProfiles "" (SDK default: identified_only) Sets posthog-js's person_profiles init option: "identified_only", "always", or "never"; anything else panics at New. A billing knob: "always" creates a person for every anonymous visitor.
Identify handler.GetUser + recipes' normalization Resolves the whoami answer. A string principal passes through, a fmt.Stringer is String()ed, anything else is anonymous; return ok=false to force anonymous.

New renders the bootstrap once — the config (including the key) is encoding/json-encoded into the served bytes, so no script-tag attributes are needed and a hostile key value stays inert: Go's JSON encoder HTML-escapes <, >, &, and a test pins that a key containing </script> never appears raw.

Attribution

Event- and session-level UTM attribution works for anonymous visitors out of the box: posthog-js registers utm_*, gclid, and friends from the first URL it sees and attaches them to every subsequent capture — including after client-side navigation drops them from the address bar. The e2e suite pins this (TestAttributionSurvivesSPAToPurchase): land on /?utm_source=twitter&gclid=G123, navigate to /pricing, click buy — the purchase event still carries utm_source=twitter and gclid=G123.

Person-level $initial_* first-touch properties are a different layer: they are written onto the person, so they require an identify() (which this package fires on login via whoami) or PersonProfiles: "always" for anonymous visitors. With the default identified_only, anonymous traffic stays event-attributed only.

When PostHog moves an endpoint

PostHog has reshuffled hosts before (the -assets split is one), and when it happens this package's two upstreams may not cover the new shape. The escape hatch is to stop composing and declare your own relay alongside — the same mechanism this package uses internally:

app.RegisterPlugin(relay.New(relay.Config{
	Routes: []relay.Route{
		{Prefix: "ph-new/", Upstream: "https://us.i.posthog.com/new-endpoint-base",
			Methods: []string{"GET", "POST"}},
	},
}))

…and point the SDK at it with the per-endpoint overrides, or vendor this package (go run ./cmd/gofastr-plugin add does not apply here — this is a plain Go package, so copy it) and edit the table. Deliberate- ly, there is no ExtraIngestPaths config knob: a list of extra paths whose upstream is implied rather than declared is how an open proxy starts.

Egress, ad-blocks, CSRF — the honest notes

These are inherited from the relay and worth reading once: framework/docs/content/relay.md covers the egress-cost model, ad-block honesty (first-party origin defeats domain-based lists only; path-based rules still match), the CSRF exemption your app-wide middleware may need for the beacon routes, and the credential-stripping contract that makes the exemption safe.

Testing with browser automation

posthog-js ships bot detection: it silently drops every capture when navigator.webdriver is set or the user agent looks headless. A Playwright/chromedp test that drives your app will load the SDK, fetch config, and capture nothing. For such tests launch the browser with --disable-blink-features=AutomationControlled and a regular user agent — real visitors are unaffected either way.

A/B testing

Experiments ride the same relay: posthog-js fetches flag definitions through the relayed /flags call, so variants, exposures, and goal metrics all work first-party with no extra configuration. Branch on the variant client-side:

// in your own page script (serve it like the bootstrap: ScriptHandler
// + RegisterExternalScript — external, first-party, CSP-clean)
posthog.onFeatureFlags(function () {
  var v = posthog.getFeatureFlag('hero-copy-test');
  if (v === 'punchy') {
    document.querySelector('h1').textContent = 'The punchy version';
  }
  // getFeatureFlag records the $feature_flag_called exposure PostHog's
  // experiment analysis keys on; no manual capture needed.
});

Assignments are sticky per visitor (anonymous device id, merged into the person on identify), and PostHog's experiment UI computes significance on whatever goal metric you capture. Server-side boolean gates go through featureflag.Store (see gofastr's analytics-recipes doc); server-side VARIANTS need posthog-go in the host app, pointed at Base().

Documentation

Overview

Package posthog is the packaged, one-call version of the PostHog recipe from gofastr's analytics-recipes docs: PostHog's script and ingestion endpoints served first-party through battery/relay, a host-authored bootstrap that loads the real posthog-js loader through the relay, identity from the app's session via a same-origin whoami endpoint, and pageviews that track GoFastr's client-side navigation.

This is an integration, not one of this repo's sandboxed heavy-JS plugins: posthog-js instruments the whole host document, so it runs in the host page, unfenced by design. The isolation story here is the relay's — the visitor's browser talks only to your origin, the strict default CSP stays untouched, and no third-party cookie ever lands on it. See posthog/README.md for the full traffic map.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Key is the PostHog project API key (phc_...). Required.
	//
	// It is a public project identifier the browser ships on every
	// beacon anyway, not a secret — which is why New panics on the key
	// shapes that ARE secrets (phx_ personal, sk_ server): one of
	// those baked into the served bootstrap leaks to every visitor.
	Key string

	// Region is the project's region: "us" (the default) or "eu". It
	// picks both relay upstreams and the ui_host the bootstrap
	// configures. Anything else panics at New.
	Region string

	// SelfHost points every route — assets, ingestion, and the
	// bootstrap's ui_host — at one self-hosted PostHog origin
	// (e.g. "http://localhost:8000" for the docker hobby deploy;
	// loopback http is the only http the relay accepts). Mutually
	// exclusive with Region: a self-hosted instance has no region.
	SelfHost string

	// Path overrides the relay mount. Default relay.DefaultPath
	// ("/__gofastr/t"). Every route this package serves — ph/, ph-assets/,
	// boot.js, whoami — lives under it; relay.New validates it.
	Path string

	// SessionReplay raises the ingestion route's request-body cap from
	// the relay's 8 MiB default to 64 MiB, the size PostHog
	// session-replay uploads can reach. Off by default, deliberately:
	// the cap is an egress number, and every accepted byte is billed
	// to your bandwidth.
	SessionReplay bool

	// RespectDNT makes the bootstrap a no-op for visitors whose browser
	// reports Do-Not-Track: no SDK script loads, no beacon fires.
	RespectDNT bool

	// PersonProfiles sets posthog-js's person_profiles init option:
	// "" (the default) omits it and the SDK uses its own default
	// ("identified_only"), or one of "identified_only", "always",
	// "never". Anything else panics at New. Read it as a billing
	// question: "always" creates a person for every anonymous visitor,
	// which is exactly what "never" is for avoiding.
	PersonProfiles string

	// Identify resolves the visitor's identity for the whoami endpoint.
	// Default: handler.GetUser with the recipes' normalization — a
	// string principal passes through, a fmt.Stringer is String()ed,
	// anything else (or nobody) is anonymous. Return ok=false to answer
	// anonymous regardless of the session.
	Identify func(*http.Request) (string, bool)
}

Config constructs a Plugin with New. Key is the only required field; the zero value of the rest is the recommended posture.

type Plugin

type Plugin struct {
	*relay.Relay
	// contains filtered or unexported fields
}

Plugin is the packaged PostHog integration: a framework.Plugin that embeds the battery/relay instance it is built on (so Base() returns the mount) and adds the rendered bootstrap, its serving route, and the identity endpoint. Construct with New, register with App.RegisterPlugin.

func New

func New(cfg Config) *Plugin

New validates cfg and constructs the Plugin. It panics on invalid configuration with a message prefixed "posthog:" — a mistyped key shape or region is a construction-time programmer error, the same posture as relay.New. Path validation (and everything else about the relay table) panics inside relay.New with its own "relay:" prefix.

func (*Plugin) Attach

func (p *Plugin) Attach(h interface{ RegisterExternalScript(string) error }) error

Attach registers the bootstrap on the host in one call: h.RegisterExternalScript(p.ScriptURL()). The parameter is the one-method interface *uihost.UIHost already satisfies, so hosts wire this without this package reaching for the concrete host type.

func (*Plugin) Init

func (p *Plugin) Init(app *framework.App) error

Init wires the integration: the embedded relay's routes (ph-assets/, ph/), the rendered bootstrap served at {mount}/boot.js with the framework's versioned-script policy (strong ETag, immutable on a matching ?v=), and the identity endpoint at {mount}/whoami.

func (*Plugin) Name

func (p *Plugin) Name() string

Name implements framework.Plugin. It shadows the embedded relay's "relay" so the plugin registers (and fails, and logs) under its own name.

func (*Plugin) ScriptURL

func (p *Plugin) ScriptURL() string

ScriptURL returns the versioned URL of the rendered bootstrap: the value to pass to (*uihost.UIHost).RegisterExternalScript — or just call Attach, which does exactly that. Computed at New from the rendered bytes, so editing nothing but the config cache-busts it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL