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..903c7536 --- /dev/null +++ b/example-apps/java-spring-boot/src/main/java/com/posthog/example/controller/BurritoController.java @@ -0,0 +1,158 @@ +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. + * + *

Identity note: this demo takes the id from a form field for simplicity. + * A real app must derive {@code distinctId} from the authenticated principal + * (session / JWT), never from an unverified request parameter — otherwise a client + * could pick any id and overwrite another person's profile. + */ + @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); + + // evaluateFlags makes a network call; if PostHog is slow or down it can throw. + // Never let flag evaluation take the page down — fall back to the flag being off. + // (This fetches on every request; a real app should cache/bound it, not fetch per render.) + boolean showNewFeature = false; + try { + PostHogFeatureFlagEvaluations flags = posthog.evaluateFlags(userId); + showNewFeature = flags.isEnabled("new-dashboard-feature"); + } catch (RuntimeException e) { + log.warn("feature flag evaluation failed", e); + } + + 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 + +