diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx
index 449a4a7..1efd2c4 100644
--- a/apps/journal/app/entry.client.tsx
+++ b/apps/journal/app/entry.client.tsx
@@ -1,14 +1,25 @@
import * as Sentry from "@sentry/react";
+import { useEffect } from "react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
+import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development");
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
- integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
+ integrations: [
+ Sentry.reactRouterV7BrowserTracingIntegration({
+ useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ }),
+ Sentry.replayIntegration(),
+ ],
environment: sentryEnvironment,
tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx
index 6802b50..51db198 100644
--- a/apps/journal/app/root.tsx
+++ b/apps/journal/app/root.tsx
@@ -1,9 +1,11 @@
+import { useEffect } from "react";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from "react-router";
import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next";
import { initI18n } from "@trails-cool/i18n";
+import { getSessionUser } from "~/lib/auth.server";
import stylesheet from "@trails-cool/ui/styles.css?url";
initI18n();
@@ -28,7 +30,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
-export default function App() {
+export async function loader({ request }: Route.LoaderArgs) {
+ const user = await getSessionUser(request);
+ return { user: user ? { id: user.id, username: user.username } : null };
+}
+
+export default function App({ loaderData }: Route.ComponentProps) {
+ const user = loaderData?.user;
+ useEffect(() => {
+ if (user) {
+ Sentry.setUser({ id: user.id, username: user.username });
+ } else {
+ Sentry.setUser(null);
+ }
+ }, [user]);
+
return ;
}
diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts
index 050a85a..1e9bb96 100644
--- a/apps/journal/app/routes.ts
+++ b/apps/journal/app/routes.ts
@@ -19,4 +19,5 @@ export default [
route("activities/new", "routes/activities.new.tsx"),
route("activities/:id", "routes/activities.$id.tsx"),
route("users/:username", "routes/users.$username.tsx"),
+ route("privacy", "routes/privacy.tsx"),
] satisfies RouteConfig;
diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx
index 196ad10..b32dd7b 100644
--- a/apps/journal/app/routes/home.tsx
+++ b/apps/journal/app/routes/home.tsx
@@ -125,6 +125,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
)}
+
+
);
}
diff --git a/apps/journal/app/routes/privacy.tsx b/apps/journal/app/routes/privacy.tsx
new file mode 100644
index 0000000..b0c1fd0
--- /dev/null
+++ b/apps/journal/app/routes/privacy.tsx
@@ -0,0 +1,98 @@
+import type { Route } from "./+types/privacy";
+
+export function meta(_args: Route.MetaArgs) {
+ return [{ title: "Privacy — trails.cool" }];
+}
+
+export default function PrivacyPage() {
+ return (
+
+
Privacy Manifest
+
+ trails.cool is committed to privacy by design. This manifest documents
+ everything we collect, why, and how you can control it.
+
+
+
+ Planner (planner.trails.cool)
+
+ The Planner collects no personal data. Sessions are anonymous —
+ there are no user accounts, no tracking, and no analytics on your routes.
+
+
+ - No cookies (except ephemeral session state)
+ - No user accounts or login
+ - No route data is stored permanently without your action
+ - Session data is automatically deleted after 7 days of inactivity
+
+
+
+
+ Journal (trails.cool)
+
+ The Journal stores the data you explicitly provide:
+
+
+ - Account data: email, username, display name, passkey credentials
+ - Routes: name, description, GPX data, geometry
+ - Activities: title, description, date, linked routes
+
+
+ All your data is exportable at any time in open formats (GPX, JSON).
+ You can self-host your own instance and migrate your data completely.
+
+
+
+
+ Error Tracking (Sentry)
+
+ Both apps use Sentry for
+ error monitoring. This helps us find and fix bugs quickly.
+
+ What Sentry collects:
+
+ - Error details: stack traces, error messages, browser/OS info
+ - Performance traces: page load times, route navigation timing
+ - Session replays on error: a recording of the session leading up to an error (DOM snapshots, not video)
+ - User context (Journal only): user ID and username are attached to errors for debugging
+ - Session ID (Planner only): the anonymous session ID is attached to errors
+
+ What Sentry does NOT collect:
+
+ - Route or GPX data
+ - Passwords or passkey credentials
+ - Form input contents (masked in replays)
+
+ Data retention:
+
+ Sentry data is retained for 90 days and then automatically deleted.
+ Sentry's servers are hosted in the EU (Frankfurt).
+
+
+
+
+ Third Parties
+
+ - Sentry (Functional Software Inc.) — error tracking, as described above
+ - OpenStreetMap — map tiles are loaded from OSM tile servers. OSM's privacy policy applies to tile requests.
+ - BRouter — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.
+
+
+
+
+ What We Don't Do
+
+ - We don't sell data
+ - We don't show ads
+ - We don't build user profiles
+ - We don't use tracking pixels or analytics
+ - We don't share data with anyone except as listed above
+
+
+
+
+ Last updated: March 2026. If this manifest changes, we'll note it here.
+
+
+ );
+}
diff --git a/apps/journal/public/robots.txt b/apps/journal/public/robots.txt
new file mode 100644
index 0000000..1f53798
--- /dev/null
+++ b/apps/journal/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/apps/journal/vite.config.ts b/apps/journal/vite.config.ts
index 44fe7d1..e799794 100644
--- a/apps/journal/vite.config.ts
+++ b/apps/journal/vite.config.ts
@@ -6,7 +6,7 @@ import path from "node:path";
export default defineConfig({
build: {
- sourcemap: true,
+ sourcemap: "hidden",
},
plugins: [
tailwindcss(),
@@ -15,6 +15,7 @@ export default defineConfig({
org: "trails-qq",
project: "journal",
release: { name: process.env.SENTRY_RELEASE },
+ sourcemaps: { filesToDeleteAfterUpload: ["./build/**/*.map"] },
disable: !process.env.SENTRY_AUTH_TOKEN,
}),
],
diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx
index b7b6251..bf56abc 100644
--- a/apps/planner/app/components/SessionView.tsx
+++ b/apps/planner/app/components/SessionView.tsx
@@ -1,5 +1,6 @@
-import { Suspense, lazy, useState, useCallback } from "react";
+import { Suspense, lazy, useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
+import * as Sentry from "@sentry/react";
import { useYjs } from "~/lib/use-yjs";
import { useRouting } from "~/lib/use-routing";
import { ProfileSelector } from "~/components/ProfileSelector";
@@ -27,6 +28,7 @@ interface SessionViewProps {
export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) {
const { t } = useTranslation("planner");
+ useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints);
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx
index b266770..b425aa0 100644
--- a/apps/planner/app/entry.client.tsx
+++ b/apps/planner/app/entry.client.tsx
@@ -1,14 +1,25 @@
import * as Sentry from "@sentry/react";
+import { useEffect } from "react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
+import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development");
Sentry.init({
dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208",
- integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
+ integrations: [
+ Sentry.reactRouterV7BrowserTracingIntegration({
+ useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ }),
+ Sentry.replayIntegration(),
+ ],
environment: sentryEnvironment,
tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
diff --git a/apps/planner/public/robots.txt b/apps/planner/public/robots.txt
new file mode 100644
index 0000000..1f53798
--- /dev/null
+++ b/apps/planner/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/apps/planner/vite.config.ts b/apps/planner/vite.config.ts
index a97a32a..cb4d8a8 100644
--- a/apps/planner/vite.config.ts
+++ b/apps/planner/vite.config.ts
@@ -7,7 +7,7 @@ import { yjsDevPlugin } from "./app/lib/vite-yjs-plugin.ts";
export default defineConfig({
build: {
- sourcemap: true,
+ sourcemap: "hidden",
},
plugins: [
tailwindcss(),
@@ -17,6 +17,7 @@ export default defineConfig({
org: "trails-qq",
project: "planner",
release: { name: process.env.SENTRY_RELEASE },
+ sourcemaps: { filesToDeleteAfterUpload: ["./build/**/*.map"] },
disable: !process.env.SENTRY_AUTH_TOKEN,
}),
],
diff --git a/openspec/changes/archive/2026-03-25-sentry-improvements/.openspec.yaml b/openspec/changes/archive/2026-03-25-sentry-improvements/.openspec.yaml
new file mode 100644
index 0000000..40c5540
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-sentry-improvements/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-25
diff --git a/openspec/changes/archive/2026-03-25-sentry-improvements/design.md b/openspec/changes/archive/2026-03-25-sentry-improvements/design.md
new file mode 100644
index 0000000..ee91875
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-sentry-improvements/design.md
@@ -0,0 +1,38 @@
+## Context
+
+Both apps have basic Sentry (`@sentry/react` + `@sentry/node`) with source map uploads, release tracking, and environment tagging. Errors appear in Sentry but lack user/session context, making triage slow. Source maps are also served publicly.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Identify which user or session caused an error without guessing
+- Route-level performance traces (not just page loads)
+- Stop serving source maps to browsers
+
+**Non-Goals:**
+- Custom Sentry dashboards or alert rules (configure in Sentry UI)
+- Server-side performance instrumentation beyond request handling
+- Profiling integration
+
+## Decisions
+
+### D1: Set user context via Sentry.setUser in root loader
+
+Journal's root loader already fetches the session user. Call `Sentry.setUser({ id, username })` on the client when authenticated, `Sentry.setUser(null)` when not. Done in root.tsx since it runs on every navigation.
+
+### D2: Tag planner errors with session ID via Sentry.setTag
+
+In `SessionView`, call `Sentry.setTag("session_id", sessionId)` on mount. This tags all subsequent errors with the active session.
+
+### D3: Use reactRouterV7BrowserTracingIntegration
+
+Sentry provides `Sentry.reactRouterV7BrowserTracingIntegration` which hooks into React Router's navigation to create route-aware spans. Replace the generic `browserTracingIntegration()` in both `entry.client.tsx` files. Requires passing `useEffect` and `useLocation` from react-router.
+
+### D4: Hidden source maps via Vite config
+
+Change `build.sourcemap` from `true` to `"hidden"`. This generates `.map` files for the Sentry plugin to upload but doesn't add `//# sourceMappingURL` comments to the bundles. Browsers won't request the map files. The Sentry plugin's `sourcemaps.filesToDeleteAfterUpload` option can also clean them from the build output.
+
+## Risks / Trade-offs
+
+- **D3 couples Sentry to React Router version** → Acceptable; we already depend on both. If we upgrade React Router, we upgrade the Sentry integration too.
+- **D4 makes browser debugging harder in production** → Acceptable; errors go to Sentry with full source context. DevTools debugging in production was never a supported workflow.
diff --git a/openspec/changes/archive/2026-03-25-sentry-improvements/proposal.md b/openspec/changes/archive/2026-03-25-sentry-improvements/proposal.md
new file mode 100644
index 0000000..9c0dd38
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-sentry-improvements/proposal.md
@@ -0,0 +1,28 @@
+## Why
+
+Sentry is deployed but errors lack context — we don't know which user or session triggered them, route-level performance is invisible, and source maps are served to clients unnecessarily. These gaps make debugging harder and leak internal details.
+
+## What Changes
+
+- Set Sentry user context when a Journal user is authenticated (user ID, username)
+- Tag Planner errors with the session ID for debugging
+- Replace generic `browserTracingIntegration` with `reactRouterV7BrowserTracingIntegration` for route-aware performance traces
+- Configure Vite to produce source maps as `hidden` (uploaded to Sentry but not referenced in the bundle, so browsers don't fetch them)
+- Wrap server-side request handlers with Sentry error capturing for unhandled exceptions
+
+## Capabilities
+
+### New Capabilities
+
+(None — this enhances the existing Sentry integration, no new behavioral capabilities.)
+
+### Modified Capabilities
+
+- `infrastructure`: Adding Sentry context enrichment and source map handling to the deployment pipeline
+
+## Impact
+
+- **Files**: `entry.client.tsx` (both apps), `entry.server.tsx` (journal), `server.ts` (planner), `vite.config.ts` (both apps), Journal root loader or layout
+- **Dependencies**: No new packages — `@sentry/react` and `@sentry/node` already support all features
+- **APIs**: No API changes
+- **Security**: Source maps no longer served to clients (currently `.map` files are publicly accessible)
diff --git a/openspec/changes/archive/2026-03-25-sentry-improvements/specs/infrastructure/spec.md b/openspec/changes/archive/2026-03-25-sentry-improvements/specs/infrastructure/spec.md
new file mode 100644
index 0000000..478e701
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-sentry-improvements/specs/infrastructure/spec.md
@@ -0,0 +1,24 @@
+## MODIFIED Requirements
+
+### Requirement: Sentry error tracking
+The system SHALL enrich Sentry events with user and session context, use route-aware tracing, and prevent source maps from being served to clients.
+
+#### Scenario: Journal error includes user context
+- **WHEN** an authenticated Journal user triggers an error
+- **THEN** the Sentry event SHALL include the user's ID and username
+
+#### Scenario: Journal error without user context
+- **WHEN** an unauthenticated visitor triggers an error
+- **THEN** the Sentry event SHALL have no user context (Sentry.setUser(null))
+
+#### Scenario: Planner error includes session ID
+- **WHEN** an error occurs during a Planner session
+- **THEN** the Sentry event SHALL include a `session_id` tag with the active session ID
+
+#### Scenario: Route-level performance traces
+- **WHEN** a user navigates between routes in either app
+- **THEN** Sentry SHALL create a transaction span named after the route pattern (e.g., `/routes/:id`)
+
+#### Scenario: Source maps not served to clients
+- **WHEN** a client requests a `.map` file from the production server
+- **THEN** the server SHALL return 404 (source maps are uploaded to Sentry during build, not shipped in the bundle)
diff --git a/openspec/changes/archive/2026-03-25-sentry-improvements/tasks.md b/openspec/changes/archive/2026-03-25-sentry-improvements/tasks.md
new file mode 100644
index 0000000..f13cff5
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-sentry-improvements/tasks.md
@@ -0,0 +1,24 @@
+## 1. User & Session Context
+
+- [x] 1.1 Set Sentry user context (id, username) in Journal root loader/component when authenticated, clear when not
+- [x] 1.2 Set Sentry `session_id` tag in Planner's SessionView on mount
+
+## 2. Route-Aware Tracing
+
+- [x] 2.1 Replace `browserTracingIntegration()` with `reactRouterV7BrowserTracingIntegration` in planner entry.client.tsx
+- [x] 2.2 Replace `browserTracingIntegration()` with `reactRouterV7BrowserTracingIntegration` in journal entry.client.tsx
+
+## 3. Source Map Hardening
+
+- [x] 3.1 Change `build.sourcemap` from `true` to `"hidden"` in both vite.config.ts files
+- [x] 3.2 Add `sourcemaps.filesToDeleteAfterUpload` to the Sentry Vite plugin config to clean up .map files after upload
+
+## 4. Privacy Manifest
+
+- [x] 4.1 Create `/privacy` route in Journal with a user-visible privacy manifest documenting: what Sentry collects (errors, traces, session replays), what the Planner does NOT collect, data retention, and third-party disclosure (Sentry)
+- [x] 4.2 Add link to privacy manifest in Journal footer/home page
+
+## 5. Verify
+
+- [x] 5.1 Test locally: build, check no `.map` files remain in build output (or no sourceMappingURL in bundles)
+- [x] 5.2 Test locally: trigger error in Journal while logged in, verify Sentry.setUser was called (check devtools)
diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md
index 69342ba..7399863 100644
--- a/openspec/specs/infrastructure/spec.md
+++ b/openspec/specs/infrastructure/spec.md
@@ -63,3 +63,26 @@ The infrastructure SHALL configure DNS and TLS for trails.cool and planner.trail
#### Scenario: HTTPS access
- **WHEN** a user navigates to https://trails.cool
- **THEN** the connection is secured with a valid TLS certificate
+
+### Requirement: Sentry error tracking
+The system SHALL enrich Sentry events with user and session context, use route-aware tracing, and prevent source maps from being served to clients.
+
+#### Scenario: Journal error includes user context
+- **WHEN** an authenticated Journal user triggers an error
+- **THEN** the Sentry event SHALL include the user's ID and username
+
+#### Scenario: Journal error without user context
+- **WHEN** an unauthenticated visitor triggers an error
+- **THEN** the Sentry event SHALL have no user context (Sentry.setUser(null))
+
+#### Scenario: Planner error includes session ID
+- **WHEN** an error occurs during a Planner session
+- **THEN** the Sentry event SHALL include a `session_id` tag with the active session ID
+
+#### Scenario: Route-level performance traces
+- **WHEN** a user navigates between routes in either app
+- **THEN** Sentry SHALL create a transaction span named after the route pattern (e.g., `/routes/:id`)
+
+#### Scenario: Source maps not served to clients
+- **WHEN** a client requests a `.map` file from the production server
+- **THEN** the server SHALL return 404 (source maps are uploaded to Sentry during build, not shipped in the bundle)
diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts
index 807b0d9..a50dca9 100644
--- a/packages/i18n/src/index.ts
+++ b/packages/i18n/src/index.ts
@@ -23,6 +23,7 @@ export function initI18n() {
interpolation: {
escapeValue: false,
},
+ showSupportNotice: false,
});
}