From 7dfb5040690a5a70f721dc4625d01b1cae21faae Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Thu, 23 Jul 2026 15:23:45 -0400 Subject: [PATCH] feat(integration): add java (spring boot) as a full example-based variant Add a Java integration variant anchored on a real Spring Boot example app (example-apps/java-spring-boot): the posthog-server client is exposed as a singleton @Bean configured from the environment, flushed and closed on shutdown via destroyMethod="close". A controller identifies users, captures events, evaluates feature flags with evaluateFlags, and reports errors as an explicit event (the Java server SDK has no automatic exception capture, documented honestly rather than faked). Ships a full Spring Boot example rather than docs-only so the wizard has a concrete shape to match for the most common Java backend framework. Adds a java-server commandments block, keyed java-server (not java) so these backend rules never fold into the Android skill, which carries the java tag; the variant is tagged [java, java-server]. Co-Authored-By: Claude Opus 4.8 --- context/commandments.yaml | 11 ++ context/skills/integration/config.yaml | 9 + example-apps/java-spring-boot/.env.example | 2 + example-apps/java-spring-boot/.gitignore | 6 + example-apps/java-spring-boot/README.md | 162 ++++++++++++++++++ example-apps/java-spring-boot/build.gradle | 24 +++ example-apps/java-spring-boot/settings.gradle | 1 + .../java/com/posthog/example/Application.java | 23 +++ .../example/config/PostHogConfiguration.java | 52 ++++++ .../example/controller/BurritoController.java | 145 ++++++++++++++++ .../src/main/resources/application.properties | 9 + .../src/main/resources/templates/burrito.html | 10 ++ .../main/resources/templates/dashboard.html | 10 ++ .../src/main/resources/templates/home.html | 25 +++ .../src/main/resources/templates/profile.html | 10 ++ 15 files changed, 499 insertions(+) create mode 100644 example-apps/java-spring-boot/.env.example create mode 100644 example-apps/java-spring-boot/.gitignore create mode 100644 example-apps/java-spring-boot/README.md create mode 100644 example-apps/java-spring-boot/build.gradle create mode 100644 example-apps/java-spring-boot/settings.gradle create mode 100644 example-apps/java-spring-boot/src/main/java/com/posthog/example/Application.java create mode 100644 example-apps/java-spring-boot/src/main/java/com/posthog/example/config/PostHogConfiguration.java create mode 100644 example-apps/java-spring-boot/src/main/java/com/posthog/example/controller/BurritoController.java create mode 100644 example-apps/java-spring-boot/src/main/resources/application.properties create mode 100644 example-apps/java-spring-boot/src/main/resources/templates/burrito.html create mode 100644 example-apps/java-spring-boot/src/main/resources/templates/dashboard.html create mode 100644 example-apps/java-spring-boot/src/main/resources/templates/home.html create mode 100644 example-apps/java-spring-boot/src/main/resources/templates/profile.html diff --git a/context/commandments.yaml b/context/commandments.yaml index 64a8b6c0..d2607573 100644 --- a/context/commandments.yaml +++ b/context/commandments.yaml @@ -97,6 +97,17 @@ commandments: - Call `IdentifyAsync` for known users and put PII such as email in person properties, not in event properties - Use `CaptureException(exception, distinctId, properties, groups, flags)` for handled exceptions; automatic exception capture is not available in the .NET SDK yet + # Keyed `java-server` (not `java`) so these backend rules never fold into the Android skill, which carries the `java` tag. The Java variant is tagged [java, java-server]. + java-server: + - posthog-server is the PostHog Java server SDK; add the `com.posthog:posthog-server` dependency via Gradle or Maven and import from `com.posthog.server` + - Build one client per process with `PostHogConfig.builder(token).host(host).build()` then `PostHog.with(config)`; share the returned `PostHogInterface` (for example as a singleton Spring `@Bean`), never per request + - Read the project token and host from the environment or configuration; never hardcode PostHog secrets + - Flush and close on shutdown with `posthog.flush()` and `posthog.close()` (for a Spring `@Bean`, use `destroyMethod = "close"`) so queued events are delivered before exit + - Server-side captures must pass a stable `distinctId` as the first argument to `capture(...)` that matches frontend identify calls; avoid anonymous or literal IDs for business events + - Identify users by attaching person properties to a capture via `PostHogCaptureOptions.builder().userProperty(...)` / `.userPropertySetOnce(...)`; keep PII in person properties, not event properties + - For feature flags, call `posthog.evaluateFlags(distinctId)` once per request, then read values from the snapshot with `isEnabled("flag-key")` / `getFlagPayload("flag-key")` + - The Java server SDK has no automatic error tracking, surveys, or session replay; do not promise or scaffold those features + elixir: - posthog-elixir is installed as the `posthog` Hex package; add `{:posthog, "~> 2.0"}` to `mix.exs` and run `mix deps.get` - Configure PostHog in application config using `api_host`, `api_key`, and `in_app_otp_apps`; read secrets from environment or runtime config, never hardcode them diff --git a/context/skills/integration/config.yaml b/context/skills/integration/config.yaml index 2699fadf..e0248514 100644 --- a/context/skills/integration/config.yaml +++ b/context/skills/integration/config.yaml @@ -236,6 +236,15 @@ variants: docs_urls: - https://posthog.com/docs/libraries/elixir.md + - id: java + framework: java + example_paths: example-apps/java-spring-boot + display_name: Java (Spring Boot) + description: PostHog integration for Java and Spring Boot applications using the posthog-server SDK + tags: [java, java-server] + docs_urls: + - https://posthog.com/docs/libraries/java.md + - id: go template: description-go-docs-only.md display_name: Go diff --git a/example-apps/java-spring-boot/.env.example b/example-apps/java-spring-boot/.env.example new file mode 100644 index 00000000..399249e8 --- /dev/null +++ b/example-apps/java-spring-boot/.env.example @@ -0,0 +1,2 @@ +POSTHOG_PROJECT_TOKEN= +POSTHOG_HOST=https://us.i.posthog.com diff --git a/example-apps/java-spring-boot/.gitignore b/example-apps/java-spring-boot/.gitignore new file mode 100644 index 00000000..5331444a --- /dev/null +++ b/example-apps/java-spring-boot/.gitignore @@ -0,0 +1,6 @@ +.env +build/ +.gradle/ +*.class +.idea/ +*.iml diff --git a/example-apps/java-spring-boot/README.md b/example-apps/java-spring-boot/README.md new file mode 100644 index 00000000..978892b5 --- /dev/null +++ b/example-apps/java-spring-boot/README.md @@ -0,0 +1,162 @@ +# PostHog Spring Boot example + +This is a [Spring Boot](https://spring.io/projects/spring-boot) example demonstrating PostHog integration with product analytics, feature flags, and user identification using the server-side Java SDK. + +## Features + +- **Product analytics**: Track user events and behaviors +- **User identification**: Associate events with authenticated users via person properties +- **Feature flags**: Control feature rollouts with PostHog feature flags +- **Server-side tracking**: All tracking happens server-side with the `posthog-server` SDK +- **Single client bean**: One PostHog client for the whole app, flushed on shutdown + +> **Note on error tracking:** the PostHog Java server SDK does not have automatic +> exception capture. This example reports failures as an explicit `error_occurred` +> event instead. Do not expect session replay or surveys from the server SDK either. + +## Getting started + +### 1. Configure environment variables + +Copy `.env.example` to `.env` and fill in your values, then export them: + +```bash +export POSTHOG_PROJECT_TOKEN=your_posthog_project_token +export POSTHOG_HOST=https://us.i.posthog.com +``` + +Get your PostHog project token from your [PostHog project settings](https://app.posthog.com/project/settings). + +### 2. Run the app + +```bash +./gradlew bootRun +``` + +Open [http://localhost:8000](http://localhost:8000) with your browser to see the app. + +## Project structure + +``` +java-spring-boot/ +├── build.gradle # Gradle build with the posthog-server dependency +├── settings.gradle +├── .env.example # Environment variable template +├── .gitignore +└── src/main/ + ├── resources/ + │ ├── application.properties # Binds POSTHOG_* env vars into config + │ └── templates/ # Thymeleaf pages + │ ├── home.html # Home / login page + │ ├── burrito.html # Event tracking + │ ├── dashboard.html # Feature flag example + │ └── profile.html # Error reporting + └── java/com/posthog/example/ + ├── Application.java # Spring Boot entry point + ├── config/ + │ └── PostHogConfiguration.java # The single PostHog client bean + └── controller/ + └── BurritoController.java # Events, identify, flags, errors +``` + +## Key integration points + +### PostHog client bean (config/PostHogConfiguration.java) + +Create **one client per process** and expose it as a singleton bean. `destroyMethod = "close"` makes Spring flush queued events when the app shuts down. + +```java +@Bean(destroyMethod = "close") +public PostHogInterface posthog( + @Value("${posthog.project-token:}") String projectToken, + @Value("${posthog.host:https://us.i.posthog.com}") String host) { + + PostHogConfig config = PostHogConfig + .builder(projectToken) + .host(host) + .build(); + + return PostHog.with(config); +} +``` + +### Configuration from the environment (application.properties) + +Read the token and host from the environment so secrets never live in source: + +```properties +posthog.project-token=${POSTHOG_PROJECT_TOKEN:} +posthog.host=${POSTHOG_HOST:https://us.i.posthog.com} +``` + +### User identification (controller/BurritoController.java) + +The server SDK identifies a user by attaching person properties to a capture with `userProperty(...)`. The `distinctId` (first argument) must match the id your frontend `identify` call uses. + +```java +posthog.capture( + userId, + "user_logged_in", + PostHogCaptureOptions + .builder() + .userProperty("email", email) + .property("login_method", "email") + .build()); +``` + +### Event tracking (controller/BurritoController.java) + +```java +posthog.capture( + userId, + "burrito_considered", + PostHogCaptureOptions + .builder() + .property("total_considerations", count) + .build()); +``` + +### Feature flags (controller/BurritoController.java) + +Evaluate flags once per request, then read individual flags off the snapshot. Avoid the deprecated per-flag helpers. + +```java +PostHogFeatureFlagEvaluations flags = posthog.evaluateFlags(userId); +boolean showNewFeature = flags.isEnabled("new-dashboard-feature"); +``` + +### Error reporting (controller/BurritoController.java) + +There is no automatic exception capture in the Java server SDK, so report failures as an explicit event: + +```java +try { + riskyOperation(); +} catch (RuntimeException e) { + posthog.capture( + userId, + "error_occurred", + PostHogCaptureOptions + .builder() + .property("error_type", e.getClass().getSimpleName()) + .property("error_message", e.getMessage()) + .build()); +} +``` + +### Flush and shutdown + +`destroyMethod = "close"` on the bean handles this for you at shutdown. If you ever +manage the client yourself, flush and close explicitly: + +```java +posthog.flush(); // send any remaining events +posthog.close(); // shut down the client +``` + +## Learn more + +- [PostHog Java integration](https://posthog.com/docs/libraries/java) +- [PostHog feature flags](https://posthog.com/docs/feature-flags) +- [PostHog documentation](https://posthog.com/docs) +- [Spring Boot documentation](https://spring.io/projects/spring-boot) diff --git a/example-apps/java-spring-boot/build.gradle b/example-apps/java-spring-boot/build.gradle new file mode 100644 index 00000000..2b3bf258 --- /dev/null +++ b/example-apps/java-spring-boot/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' + id 'java' +} + +group = 'com.posthog' +version = '0.0.1' + +java { + sourceCompatibility = '17' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // PostHog server-side Java SDK + implementation 'com.posthog:posthog-server:2.+' +} diff --git a/example-apps/java-spring-boot/settings.gradle b/example-apps/java-spring-boot/settings.gradle new file mode 100644 index 00000000..f291fe9c --- /dev/null +++ b/example-apps/java-spring-boot/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'posthog-java-spring-boot-example' diff --git a/example-apps/java-spring-boot/src/main/java/com/posthog/example/Application.java b/example-apps/java-spring-boot/src/main/java/com/posthog/example/Application.java new file mode 100644 index 00000000..f89a255b --- /dev/null +++ b/example-apps/java-spring-boot/src/main/java/com/posthog/example/Application.java @@ -0,0 +1,23 @@ +package com.posthog.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Entry point for the PostHog Spring Boot example. + * + *

The interesting integration code lives in: + *

+ */ +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/example-apps/java-spring-boot/src/main/java/com/posthog/example/config/PostHogConfiguration.java b/example-apps/java-spring-boot/src/main/java/com/posthog/example/config/PostHogConfiguration.java new file mode 100644 index 00000000..17a5a4e4 --- /dev/null +++ b/example-apps/java-spring-boot/src/main/java/com/posthog/example/config/PostHogConfiguration.java @@ -0,0 +1,52 @@ +package com.posthog.example.config; + +import com.posthog.server.PostHog; +import com.posthog.server.PostHogConfig; +import com.posthog.server.PostHogInterface; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires a single PostHog client into the Spring context. + * + *

Key integration points demonstrated here: + *

+ */ +@Configuration +public class PostHogConfiguration { + + private static final Logger log = LoggerFactory.getLogger(PostHogConfiguration.class); + + @Bean(destroyMethod = "close") + public PostHogInterface posthog( + @Value("${posthog.project-token:}") String projectToken, + @Value("${posthog.host:https://us.i.posthog.com}") String host) { + + if (projectToken == null || projectToken.isBlank()) { + log.warn("POSTHOG_PROJECT_TOKEN is not set. PostHog events will not be delivered. " + + "Set it in your environment (see .env.example) to enable analytics."); + } + + PostHogConfig config = PostHogConfig + .builder(projectToken) + .host(host) + .build(); + + return PostHog.with(config); + } +} diff --git a/example-apps/java-spring-boot/src/main/java/com/posthog/example/controller/BurritoController.java b/example-apps/java-spring-boot/src/main/java/com/posthog/example/controller/BurritoController.java new file mode 100644 index 00000000..31ecf77d --- /dev/null +++ b/example-apps/java-spring-boot/src/main/java/com/posthog/example/controller/BurritoController.java @@ -0,0 +1,145 @@ +package com.posthog.example.controller; + +import com.posthog.server.PostHogCaptureOptions; +import com.posthog.server.PostHogFeatureFlagEvaluations; +import com.posthog.server.PostHogInterface; +import jakarta.servlet.http.HttpSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * A tiny "burrito" app that mirrors the other PostHog example apps. + * + *

Each handler shows one integration point. The PostHog client is injected once + * (constructor injection) and reused — the same singleton bean from + * {@link com.posthog.example.config.PostHogConfiguration}. + */ +@Controller +public class BurritoController { + + private static final Logger log = LoggerFactory.getLogger(BurritoController.class); + + private final PostHogInterface posthog; + + public BurritoController(PostHogInterface posthog) { + this.posthog = posthog; + } + + /** Home / login page. */ + @GetMapping("/") + public String home(HttpSession session, Model model) { + model.addAttribute("userId", session.getAttribute("userId")); + return "home"; + } + + /** + * User identification. + * + *

The server SDK identifies a user by attaching person properties to a capture + * via {@code userProperty(...)}. The {@code distinctId} (first argument) is the + * stable user id and must match the id used by your frontend {@code identify} call. + */ + @PostMapping("/login") + public String login(@RequestParam String userId, + @RequestParam(required = false) String email, + HttpSession session) { + session.setAttribute("userId", userId); + + posthog.capture( + userId, + "user_logged_in", + PostHogCaptureOptions + .builder() + .userProperty("email", email == null ? "" : email) + .property("login_method", "email") + .build()); + + return "redirect:/"; + } + + /** + * Event tracking. + * + *

Capture a meaningful business event with a stable distinct id and a couple of + * event properties. Keep PII in person properties (see {@link #login}), not here. + */ + @PostMapping("/burrito") + public String considerBurrito(HttpSession session, Model model) { + String userId = distinctId(session); + Object previous = session.getAttribute("burritoCount"); + int count = (previous == null ? 0 : (int) previous) + 1; + session.setAttribute("burritoCount", count); + + posthog.capture( + userId, + "burrito_considered", + PostHogCaptureOptions + .builder() + .property("total_considerations", count) + .build()); + + model.addAttribute("count", count); + return "burrito"; + } + + /** + * Feature flags. + * + *

Evaluate flags once per request with {@code evaluateFlags(distinctId)}, then read + * individual flags off the returned snapshot. Avoid the deprecated per-flag helpers. + */ + @GetMapping("/dashboard") + public String dashboard(HttpSession session, Model model) { + String userId = distinctId(session); + + PostHogFeatureFlagEvaluations flags = posthog.evaluateFlags(userId); + boolean showNewFeature = flags.isEnabled("new-dashboard-feature"); + + model.addAttribute("showNewFeature", showNewFeature); + return "dashboard"; + } + + /** + * Error reporting. + * + *

Note: the PostHog Java server SDK does not have automatic exception capture, so + * we report failures as an explicit event with the exception details. Do not promise + * or scaffold error-tracking features the SDK does not support. + */ + @GetMapping("/profile") + public String profile(HttpSession session, Model model) { + String userId = distinctId(session); + try { + riskyOperation(); + } catch (RuntimeException e) { + log.warn("profile risky_operation failed", e); + posthog.capture( + userId, + "error_occurred", + PostHogCaptureOptions + .builder() + .property("error_type", e.getClass().getSimpleName()) + .property("error_message", e.getMessage()) + .property("source", "profile_view") + .build()); + model.addAttribute("error", e.getMessage()); + } + model.addAttribute("userId", userId); + return "profile"; + } + + private void riskyOperation() { + throw new IllegalStateException("profile data source is temporarily unavailable"); + } + + /** Falls back to "anonymous" when no one has logged in yet. */ + private String distinctId(HttpSession session) { + Object userId = session.getAttribute("userId"); + return userId == null ? "anonymous" : userId.toString(); + } +} diff --git a/example-apps/java-spring-boot/src/main/resources/application.properties b/example-apps/java-spring-boot/src/main/resources/application.properties new file mode 100644 index 00000000..ae4549ba --- /dev/null +++ b/example-apps/java-spring-boot/src/main/resources/application.properties @@ -0,0 +1,9 @@ +# PostHog configuration. +# Values come from the environment so secrets never live in source. +# See .env.example for the variables this app expects. +posthog.project-token=${POSTHOG_PROJECT_TOKEN:} +posthog.host=${POSTHOG_HOST:https://us.i.posthog.com} + +# Do not cache Thymeleaf templates during local development. +spring.thymeleaf.cache=false +server.port=8000 diff --git a/example-apps/java-spring-boot/src/main/resources/templates/burrito.html b/example-apps/java-spring-boot/src/main/resources/templates/burrito.html new file mode 100644 index 00000000..6792f7d2 --- /dev/null +++ b/example-apps/java-spring-boot/src/main/resources/templates/burrito.html @@ -0,0 +1,10 @@ + + +Burrito considered + +

🌯 Burrito considered

+

You have considered a burrito 0 time(s).

+

A burrito_considered event was sent to PostHog.

+ Back home + + diff --git a/example-apps/java-spring-boot/src/main/resources/templates/dashboard.html b/example-apps/java-spring-boot/src/main/resources/templates/dashboard.html new file mode 100644 index 00000000..8fcefb59 --- /dev/null +++ b/example-apps/java-spring-boot/src/main/resources/templates/dashboard.html @@ -0,0 +1,10 @@ + + +Dashboard + +

Dashboard

+

✨ The new-dashboard-feature flag is enabled for you.

+

The new-dashboard-feature flag is off for you.

+ Back home + + diff --git a/example-apps/java-spring-boot/src/main/resources/templates/home.html b/example-apps/java-spring-boot/src/main/resources/templates/home.html new file mode 100644 index 00000000..e8b2900d --- /dev/null +++ b/example-apps/java-spring-boot/src/main/resources/templates/home.html @@ -0,0 +1,25 @@ + + +PostHog Spring Boot example + +

🌯 Burrito app

+ +
+

Logged in as user.

+ +
+ +
+
+ +
+

Log in to identify yourself with PostHog:

+ + + +
+ + diff --git a/example-apps/java-spring-boot/src/main/resources/templates/profile.html b/example-apps/java-spring-boot/src/main/resources/templates/profile.html new file mode 100644 index 00000000..abcf7ca5 --- /dev/null +++ b/example-apps/java-spring-boot/src/main/resources/templates/profile.html @@ -0,0 +1,10 @@ + + +Profile + +

Profile

+

Something went wrong: error.

+

An error_occurred event was sent to PostHog.

+ Back home + +