An independent PostHog integration for WordPress. Connect your PostHog project, configure what gets tracked, and load PostHog on your site, with no code.
Tagbridge is not affiliated with, endorsed by, or sponsored by PostHog.
This is the developer README. End-user documentation lives in readme.txt.
Three layers, with a hard line between them:
src/Core/— platform-agnostic PHP with zero WordPress function calls: host resolution, the event-name schema, the posthog-php client wrapper, and the identity resolver. The reusable heart of the plugin.src/Modules/— integrations.Modules/PostHog/is the PostHog integration: the front-end snippet (Frontend/Enqueue), server-side listeners (Listeners/CoreEvents,Listeners/WooEvents), the eventDispatcher, identity, product metadata, and the settings + settings panel. Another analytics tool would be added as a sibling module.src/Platform/— WordPress glue: the admin settings shell, options storage, admin notices, and the module registry that boots enabled modules.
- Names: server-side event names live in
src/Core/Events/Schema.php. - Server-side:
Modules/PostHog/Listeners/{CoreEvents,WooEvents}capture via theDispatcherandCore/Events/ServerClient(posthog-php). HPOS-safe and flushed onshutdown; the WooCommerce listener only registers when WooCommerce is active.Modules/PostHog/ProductMetaadds category/attribute context. Because backend SDKs ignore the clientperson_profilessetting and create a person for every event, theDispatchersets$process_person_profile => falseon anonymous events (non-wp_distinct ids, perResolver::is_user_id()) when the site isidentified_only, so only logged-in users get a profile server-side. - Client-side: the posthog-js snippet (
Frontend/Enqueue) handles pageviews, autocapture, heatmaps, session replay, and exceptions;assets/js/variations.jscapturesproduct_variant_selectedon product pages. On logout, a one-shot flag cookie makes the next page load callposthog.reset()so the identified id does not linger in the browser. - The full event and property list is in
readme.txt.
Server-side events are stamped with the visitor's user agent ($raw_user_agent)
and IP ($ip) in Dispatcher::with_request_context(), so PostHog can attribute
geography and run bot detection (isLikelyBot / getBotName) on them — the same
way it does for browser events.
The same method also stamps the visitor's session id ($session_id), read
from the posthog-js cookie by Resolver::parse_session_id() (the $sesid key,
[last_activity, session_id, session_start]) and reused verbatim. That id is a
UUIDv7, so it satisfies PostHog's rules for a custom $session_id and links the
server event to the browser's session replay — without it, commerce events like
product_viewed can't be used to filter recordings. It is stamped only while the
browser would still treat the session as current (not idle past 30 min, not older
than 24 h), and is absent for browserless requests (payment-gateway / admin
callbacks) and in cookieless mode.
The IP is resolved by Dispatcher::client_ip() to work behind a CDN or reverse
proxy, where REMOTE_ADDR is the proxy rather than the visitor. It tries, in
order:
- the
tagbridge_server_event_ipfilter (override hook for an unusual chain), CF-Connecting-IP(Cloudflare),X-Real-IP(nginx / load balancer / CDN),- the trustworthy hop of
X-Forwarded-For— a Google Cloud load balancer appends<client>, <lb>, so the visitor is the second-to-last entry, REMOTE_ADDR, but only when it is itself a public address.
This covers both Cloudflare and Google Cloud / nginx fronts (e.g. Closte) out of the box; anything more exotic can be corrected with the filter.
Events that fire without a browser request (payment-gateway or admin order callbacks) carry no user agent by design. Configure the PostHog Filter Bot Events transformation to keep events where the user agent is not set, so real browserless orders are not dropped along with the bots.
- Runtime: PHP 8.2+, WordPress 5.8+. WooCommerce is optional.
- Dev: PHP 8.2+ and Composer, Node 18+, and Docker (for
wp-env).
composer install # PHP dev tools + posthog-php
npm install # wp-env + build tooling
npm run env:start # WordPress at http://localhost:8888 (admin / password)composer lint # PHPCS (WordPress Coding Standards)
composer lint:fix # auto-fix
composer test:unit # PHPUnit unit tests (Core)bin/build-zip.sh # produces dist/tagbridge.zip (production files only)tagbridge.php Main plugin file: header, guards, bootstrap
uninstall.php Uninstall cleanup
readme.txt WordPress.org readme (user docs + full event list)
src/Core/ Platform-agnostic logic (no WP calls)
src/Modules/PostHog/ The PostHog integration (snippet, listeners, settings)
src/Platform/ WordPress glue (admin shell, options, module registry)
assets/ Admin CSS/JS and the front-end variations script
bin/build-zip.sh Builds the distributable ZIP
languages/ Translation template (.pot)
tests/ PHPUnit tests
When the PostHog region is set to "Self-hosted or reverse proxy," the admin
supplies a custom host URL. The server then makes requests to that URL: a
validation POST (Modules\PostHog\Connection\Validator) and, when server-side
events are enabled, event delivery via posthog-php. A server fetching an
admin-supplied URL is the classic shape of a Server-Side Request Forgery (SSRF)
vector — a crafted internal URL (e.g. a cloud metadata endpoint like
http://169.254.169.254/...) could be reached from inside the host's network.
This is accepted as-is for now, because:
- Only users with
manage_optionscan set the custom host. Such a user can already install plugins and run arbitrary PHP, so this grants no new capability on a single-site install. - The custom host is a documented, advertised feature. Hardening with
wp_safe_remote_*/wp_http_validate_url()would block private/internal IPs and break legitimate self-hosted or reverse-proxy installs on internal networks.
If a stronger threat model is needed later (e.g. multisite with semi-trusted
site admins), the recommended middle ground is to reject only the cloud-metadata
link-local range (169.254.169.254) in the validator before saving — this
removes the highest-value SSRF target without breaking internal self-hosted
hosts.
GPL-2.0-or-later.