Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ SEED_ADMIN_PASSWORD="your-admin-password"
# Database (use this for Docker setup)
DATABASE_URL=postgresql://postgres:password@localhost:5432/nestjs_app

# Sentry — get the DSN from sentry.io > Project Settings > Client Keys (DSN).
# Leave blank to disable Sentry (SDK is a no-op without a DSN).
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"

# Server
PORT=3001
NODE_ENV=development
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^10.4.4",
"@repo/database": "*",
"@sentry/nestjs": "^10.63.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { SentryModule } from "@sentry/nestjs/setup";
import { ConfigModule } from "@nestjs/config";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
Expand All @@ -11,6 +12,7 @@ import { S3Module } from "./s3/s3.module";

@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
}),
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/bun-error.filter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ExceptionFilter, Catch, ArgumentsHost, Logger, HttpException } from '@nestjs/common';
import { captureException } from '@sentry/nestjs';

@Catch()
export class BunErrorFilter implements ExceptionFilter {
Expand All @@ -18,6 +19,10 @@ export class BunErrorFilter implements ExceptionFilter {
return;
}

// Expected HttpExceptions were handled above — anything past this point
// is an unexpected failure worth reporting to Sentry
captureException(exception);

// 🔍 UNWRAP BUN AGGREGATE ERRORS
if (exception instanceof AggregateError || exception.name === 'AggregateError') {
this.logger.error('💥 AGGREGATE ERROR DETECTED 💥');
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/src/instrument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from "@sentry/nestjs";

// Must be imported before any other module in main.ts so Sentry can
// instrument Node built-ins before NestJS loads them. Note: the backend
// runs under Bun, so OpenTelemetry auto-instrumentation is limited —
// error capture and manual spans work, automatic http/db spans may not.
Sentry.init({
debug: process.env.NODE_ENV === "development",
dsn: process.env.SENTRY_DSN,
environment: process.env.SENTRY_ENVIRONMENT ?? "production",
release: process.env.SENTRY_RELEASE,
// Tracing intentionally disabled for now — errors and logs only
enableLogs: true,
});
6 changes: 6 additions & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Sentry must be initialised before NestJS or any other import
import "./instrument";

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { BunErrorFilter } from "./bun-error.filter";
Expand All @@ -7,6 +10,9 @@ import cookieParser from "cookie-parser";
async function bootstrap() {
const app = await NestFactory.create(AppModule);

// Flush pending Sentry events on SIGTERM/SIGINT
app.enableShutdownHooks();

// 1. Enable CORS for local, Railway-default, and custom domains
app.enableCors({
origin: [
Expand Down
9 changes: 8 additions & 1 deletion apps/frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
# Copy this to .env and update values for your environment (Railway, etc.)
VITE_API_BASE_URL="http://localhost:3001"
VITE_API_BASE_URL="http://localhost:3001"

# Sentry — get the DSN from sentry.io > Project Settings > Client Keys (DSN).
# The DSN is public-safe. VITE_ prefix exposes it to the browser bundle.
VITE_SENTRY_DSN=""
# Server-side (SSR) DSN — usually the same value
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "app/lib/telemetryUtils";
import {
processCompressions,
filterLowActivityOutliers,
SuspensionActivity,
} from "app/lib/run-analysis";
import { getSeriesColor } from "app/lib/graphColors";
Expand Down Expand Up @@ -77,30 +78,6 @@ type PreparedSeries = {

const flipY = (pt: LinePoint): LinePoint => ({ x: pt.x, y: Math.abs(pt.y) });

function filterOutliers(
suspensionActivity: SuspensionActivity[],
): SuspensionActivity[] {
if (suspensionActivity.length === 0) return suspensionActivity;

const velocities = suspensionActivity.map((a) => a.velocity);
const displacements = suspensionActivity.map((a) => a.displacement);

const mean = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length;
const std = (arr: number[], m: number) =>
Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);

const vMean = mean(velocities);
const vStd = std(velocities, vMean);
const dMean = mean(displacements);
const dStd = std(displacements, dMean);

return suspensionActivity.filter(
(a) =>
Math.abs(a.velocity - vMean) < 5 * vStd &&
Math.abs(a.displacement - dMean) < 5 * dStd,
);
}

export const ReboundCompressionPlot: React.FC<ReboundCompressionPlotProps> = ({
title,
series,
Expand All @@ -127,11 +104,13 @@ export const ReboundCompressionPlot: React.FC<ReboundCompressionPlotProps> = ({
y: a.displacement,
});

const compressions = filterOutliers(
const compressions = filterLowActivityOutliers(
activities.filter((a) => a.type === "compression"),
length,
);
const rebounds = filterOutliers(
const rebounds = filterLowActivityOutliers(
activities.filter((a) => a.type === "rebound"),
length,
);

const compressionPoints = compressions.map(toPoint);
Expand Down
155 changes: 107 additions & 48 deletions apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,133 @@
import React, { useMemo } from "react";
import {
LineHistogram,
import {
LineHistogram,
LineHistogramSeries } from "../base/LineHistogram";
import { RawSuspensionData } from '../../../lib/telemetryUtils';
import {
buildVelocitySamples,
RawSuspensionData} from '../../../lib/telemetryUtils';
processCompressions,
filterLowActivityOutliers,
} from "app/lib/run-analysis";
import { getSeriesColor } from "app/lib/graphColors";

interface VelocityHistogramSeries {
label: string;
rawData: RawSuspensionData[];
freq: number;
fillColor?: string;
min?: number;
max?: number;
length?: number;
}

interface VelocityHistogramProps {
rawData?: RawSuspensionData[];
freq?: number;
series?: {
label: string;
rawData: RawSuspensionData[];
freq: number;
fillColor?: string;
min?: number;
max?: number;
}[];
series: VelocityHistogramSeries[];
title?: string;
fillColor?: string;
height?: number;
min?: number;
max?: number;
}

// Renders a histogram of velocity values
// Cache filtered velocities keyed by the stable rawData array reference, so the
// heavy processCompressions + filtering work is not repeated on every render.
const velocityCache = new WeakMap<
RawSuspensionData[],
{
freq: number;
length: number;
min: number;
max: number;
result: number[];
}
>();

// Builds the same stroke-velocity points (mm/s) the speed scatter plots use,
// so the histogram is a distribution view of identical, identically-filtered data.
function buildFilteredVelocities(seriesItem: VelocityHistogramSeries): number[] {
const length = seriesItem.length ?? 220;
const min = seriesItem.min ?? 0;
const max = seriesItem.max ?? 4096;
const freq = seriesItem.freq;

const cached = velocityCache.get(seriesItem.rawData);
if (
cached &&
cached.freq === freq &&
cached.length === length &&
cached.min === min &&
cached.max === max
) {
return cached.result;
}

const activities = processCompressions(
seriesItem.rawData,
freq,
length,
min,
max,
);

// Filter compression and rebound subsets separately, exactly as the scatter
// does, then combine so the histogram bins precisely the scatter's points.
const kept = [
...filterLowActivityOutliers(
activities.filter((a) => a.type === "compression"),
length,
),
...filterLowActivityOutliers(
activities.filter((a) => a.type === "rebound"),
length,
),
];

const result = kept.map((a) => a.velocity);
velocityCache.set(seriesItem.rawData, { freq, length, min, max, result });
return result;
}

// Renders a histogram of suspension stroke speeds (mm/s)
export const VelocityHistogram: React.FC<VelocityHistogramProps> = ({
rawData = [],
freq = 100,
series,
title = "Suspension Velocity",
title = "Suspension Speed",
fillColor = "hsl(var(--chart-1))",
height = 200,
min,
max,
}) => {
// buildVelocitySamples on rawData to get velocity samples
const histogramSeries = useMemo<LineHistogramSeries[]>(() => {
if (series && series.length > 0) {
return series.map((seriesItem, index) => ({
label: seriesItem.label,
color: getSeriesColor(index, seriesItem.fillColor, fillColor),
data: buildVelocitySamples(seriesItem.rawData, seriesItem.freq, seriesItem.min, seriesItem.max).map(s => s.velocity),
}));
}

return [
{
label: title,
color: fillColor,
data: buildVelocitySamples(rawData, freq, min, max).map(s => s.velocity),
},
];
}, [series, rawData, freq, min, max, fillColor, title]);
const histogramSeries = useMemo<LineHistogramSeries[]>(
() =>
series.map((seriesItem, index) => ({
label: seriesItem.label,
color: getSeriesColor(index, seriesItem.fillColor, fillColor),
data: buildFilteredVelocities(seriesItem),
})),
[series, fillColor],
);

// Keep layout stable
if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) {
return <div className="h-40 flex items-center justify-center text-gray-500 text-sm">No data</div>;
// Auto-scale the x-axis to the actual mm/s velocity range (symmetric about 0).
const xDomain = useMemo<[number, number]>(() => {
let maxAbs = 0;
for (const s of histogramSeries) {
for (const v of s.data) {
const abs = Math.abs(v);
if (abs > maxAbs) maxAbs = abs;
}
}
const bound = maxAbs > 0 ? maxAbs * 1.05 : 100;
return [-bound, bound];
}, [histogramSeries]);

// Keep layout stable
if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) {
return <div className="h-40 flex items-center justify-center text-gray-500 text-sm">No data</div>;
}

return (
return (
<div className="w-full">
<LineHistogram
<LineHistogram
series={histogramSeries}
xDomain={[-4000,4000]}
xDomain={xDomain}
height={height}
title={title}
binCount={50}
/>
/>
</div>
);
);
};
2 changes: 1 addition & 1 deletion apps/frontend/app/components/runs/summary-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function getComponentRecommendations(
if (bottomOutCount > BOTTOM_OUT_COUNT_THRESHOLD) {
items.push(`${sagWarning}Add a volume spacer (${bottomOutCount} bottom-outs)`);
} else if (bottomOutCount === 0 && maxTravel !== null && maxTravel < BOTTOM_OUT_TRAVEL_MIN) {
items.push(`${sagWarning}Remove volume spacer (never reached travel)`);
items.push(`${sagWarning}Remove volume spacer (never reached full travel)`);
}
}

Expand Down
14 changes: 13 additions & 1 deletion apps/frontend/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import * as Sentry from "@sentry/react-router";
import { HydratedRouter } from "react-router/dom";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
// Tracing is intentionally disabled for now (no tracesSampleRate) —
// errors and replays only. Re-add reactRouterTracingIntegration() and
// trace meta-tag injection in entry.server.tsx when enabling it.
integrations: [Sentry.replayIntegration()],
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
<HydratedRouter onError={Sentry.sentryOnError} />
</StrictMode>
);
});
Loading
Loading