diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankCanvas.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankCanvas.tsx index 4b91e38c7b..7cbe80001c 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankCanvas.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankCanvas.tsx @@ -1,5 +1,8 @@ import React, { FunctionComponent, ReactElement } from "react"; -import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize"; +import DefaultDashboardSize, { + GetDashboardUnitWidthInPx, + SpaceBetweenUnitsInPx, +} from "Common/Types/Dashboard/DashboardSize"; import BlankRowElement from "./BlankRow"; import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig"; @@ -21,7 +24,10 @@ const BlankCanvasElement: FunctionComponent = ( if (!props.isEditMode && props.dashboardViewConfig.components.length === 0) { return ( -
+
= ( ); } - // have a grid with width cols and height rows + const gap: number = SpaceBetweenUnitsInPx; + const unitSize: number = GetDashboardUnitWidthInPx( + props.totalCurrentDashboardWidthInPx, + ); + return (
{Array.from(Array(height).keys()).map((_: number, index: number) => { return ( { diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankDashboardUnit.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankDashboardUnit.tsx index bce7a2ff70..875663254e 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankDashboardUnit.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankDashboardUnit.tsx @@ -1,47 +1,32 @@ -import { - GetDashboardUnitHeightInPx, - MarginForEachUnitInPx, -} from "Common/Types/Dashboard/DashboardSize"; import React, { FunctionComponent, ReactElement } from "react"; export interface ComponentProps { isEditMode: boolean; onClick: () => void; - currentTotalDashboardWidthInPx: number; id: string; } const BlankDashboardUnitElement: FunctionComponent = ( props: ComponentProps, ): ReactElement => { - const heightOfUnitInPx: number = GetDashboardUnitHeightInPx( - props.currentTotalDashboardWidthInPx, - ); - - const widthOfUnitInPx: number = heightOfUnitInPx; // its a square - - let className: string = "transition-all duration-150"; - - if (props.isEditMode) { - className += - " rounded-md cursor-pointer"; - } - return (
{ props.onClick(); }} style={{ - width: widthOfUnitInPx + "px", - height: heightOfUnitInPx + "px", - margin: MarginForEachUnitInPx + "px", - border: props.isEditMode ? "1px solid rgba(203, 213, 225, 0.4)" : "none", + border: props.isEditMode + ? "1px solid rgba(203, 213, 225, 0.4)" + : "none", borderRadius: "6px", }} - >
+ /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankRow.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankRow.tsx index 09753588be..355bb6e686 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankRow.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/BlankRow.tsx @@ -6,7 +6,6 @@ export interface ComponentProps { rowNumber: number; onClick: (top: number, left: number) => void; isEditMode: boolean; - totalCurrentDashboardWidthInPx: number; } const BlankRowElement: FunctionComponent = ( @@ -20,9 +19,6 @@ const BlankRowElement: FunctionComponent = ( (_: number, index: number) => { return ( { diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx index f6bcdfbebc..d50056df4a 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx @@ -1,7 +1,10 @@ import React, { FunctionComponent, ReactElement } from "react"; import BlankCanvasElement from "./BlankCanvas"; import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig"; -import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize"; +import DefaultDashboardSize, { + GetDashboardUnitWidthInPx, + SpaceBetweenUnitsInPx, +} from "Common/Types/Dashboard/DashboardSize"; import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent"; import BlankDashboardUnitElement from "./BlankDashboardUnit"; import DashboardBaseComponentElement from "../Components/DashboardBaseComponent"; @@ -34,6 +37,11 @@ const DashboardCanvas: FunctionComponent = ( const dashboardCanvasRef: React.RefObject = React.useRef(null); + const gap: number = SpaceBetweenUnitsInPx; + const unitSize: number = GetDashboardUnitWidthInPx( + props.currentTotalDashboardWidthInPx, + ); + const renderComponents: GetReactElementFunction = (): ReactElement => { const canvasHeight: number = props.dashboardViewConfig.heightInDashboardUnits || @@ -52,7 +60,7 @@ const DashboardCanvas: FunctionComponent = ( grid[row] = new Array(canvasWidth).fill(null); } - let maxHeightInDashboardUnits: number = 0; // max height of the grid + let maxHeightInDashboardUnits: number = 0; // Place components in the grid allComponents.forEach((component: DashboardBaseComponent) => { @@ -106,16 +114,11 @@ const DashboardCanvas: FunctionComponent = ( if (!component) { if (!props.isEditMode && i >= maxHeightInDashboardUnits) { - // if we are not in edit mode, we should not render blank units continue; } - // render a blank unit renderedComponents.push( { @@ -128,8 +131,6 @@ const DashboardCanvas: FunctionComponent = ( } } - // remove nulls from the renderedComponents array - const finalRenderedComponents: Array = renderedComponents.filter( (component: ReactElement | null): component is ReactElement => { @@ -137,29 +138,27 @@ const DashboardCanvas: FunctionComponent = ( }, ); - const width: number = DefaultDashboardSize.widthInDashboardUnits; - - const canvasClassName: string = `grid grid-cols-${width}`; - return (
{finalRenderedComponents}
@@ -208,18 +207,21 @@ const DashboardCanvas: FunctionComponent = ( props.selectedComponentId?.toString() === componentId.toString(); const component: DashboardBaseComponent | undefined = - props.dashboardViewConfig.components.find((c: DashboardBaseComponent) => { - return c.componentId.toString() === componentId.toString(); - }); + props.dashboardViewConfig.components.find( + (c: DashboardBaseComponent) => { + return c.componentId.toString() === componentId.toString(); + }, + ); - const currentUnitSizeInPx: number = - props.currentTotalDashboardWidthInPx / 12; + const w: number = component?.widthInDashboardUnits || 0; + const h: number = component?.heightInDashboardUnits || 0; + + // Compute pixel dimensions for child component rendering (charts, etc.) + const widthOfComponentInPx: number = + unitSize * w + gap * (w - 1); const heightOfComponentInPx: number = - currentUnitSizeInPx * (component?.heightInDashboardUnits || 0); - - const widthOfComponentInPx: number = - currentUnitSizeInPx * (component?.widthInDashboardUnits || 0); + unitSize * h + gap * (h - 1); return ( = ( dashboardComponentHeightInPx={heightOfComponentInPx} metricTypes={props.metrics.metricTypes} dashboardStartAndEndDate={props.dashboardStartAndEndDate} - dashboardCanvasWidthInPx={dashboardCanvasRef.current?.clientWidth || 0} + dashboardCanvasWidthInPx={ + dashboardCanvasRef.current?.clientWidth || 0 + } dashboardCanvasTopInPx={dashboardCanvasRef.current?.clientTop || 0} dashboardCanvasLeftInPx={dashboardCanvasRef.current?.clientLeft || 0} totalCurrentDashboardWidthInPx={props.currentTotalDashboardWidthInPx} @@ -244,7 +248,6 @@ const DashboardCanvas: FunctionComponent = ( isSelected={isSelected} refreshTick={props.refreshTick} onClick={() => { - // component is selected props.onComponentSelected(componentId); }} /> @@ -274,7 +277,6 @@ const DashboardCanvas: FunctionComponent = ( description="Edit the settings of this component" dashboardViewConfig={props.dashboardViewConfig} onClose={() => { - // unselect this component. props.onComponentUnselected(); }} onComponentDelete={() => { diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx index 171d727512..867324ee3e 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx @@ -25,7 +25,6 @@ import DefaultDashboardSize, { GetDashboardComponentWidthInDashboardUnits, GetDashboardUnitHeightInPx, GetDashboardUnitWidthInPx, - MarginForEachUnitInPx, SpaceBetweenUnitsInPx, } from "Common/Types/Dashboard/DashboardSize"; import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes"; @@ -93,9 +92,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( // ── Minimal React state (only for hover gating) ─────────── const [isHovered, setIsHovered] = useState(false); - // We track "is dragging" in a ref so the mousemove handler never - // depends on React state. A *second* copy in useState lets the - // JSX read it for className changes on mount/unmount of the drag. const [isDragging, setIsDragging] = useState(false); // ── Refs ────────────────────────────────────────────────── @@ -107,7 +103,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( useRef(null); const overlayRef: React.MutableRefObject = useRef(null); - // Keep latest props/component available for the imperative handlers. const latestProps: React.MutableRefObject = useRef(props); const latestComponent: React.MutableRefObject = @@ -115,14 +110,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( latestProps.current = props; latestComponent.current = component; - // ── Pixel helpers ───────────────────────────────────────── - const unitW: number = GetDashboardUnitWidthInPx( - props.totalCurrentDashboardWidthInPx, - ); - const unitH: number = GetDashboardUnitHeightInPx( - props.totalCurrentDashboardWidthInPx, - ); - // ── Core imperative handlers (stable — no deps) ────────── function updateTooltip(session: DragSession): void { @@ -150,6 +137,7 @@ const DashboardBaseComponentElement: FunctionComponent = ( const uH: number = GetDashboardUnitHeightInPx( p.totalCurrentDashboardWidthInPx, ); + const g: number = SpaceBetweenUnitsInPx; const dxPx: number = e.clientX - s.startMouseX; const dyPx: number = e.clientY - s.startMouseY; @@ -160,11 +148,9 @@ const DashboardBaseComponentElement: FunctionComponent = ( } if (s.mode === "move") { - // Pure CSS transform — no React render el.style.transform = `translate(${dxPx}px, ${dyPx}px) scale(1.01)`; el.style.zIndex = "100"; - // Compute snapped grid position for the tooltip & commit const dxUnits: number = Math.round(dxPx / uW); const dyUnits: number = Math.round(dyPx / uH); @@ -183,26 +169,33 @@ const DashboardBaseComponentElement: FunctionComponent = ( updateTooltip(s); } else { - // Resize modes — directly set width / height on the DOM element const rect: DOMRect = el.getBoundingClientRect(); if (s.mode === "resize-w" || s.mode === "resize-corner") { - const wPx: number = Math.max(uW, e.pageX - (window.scrollX + rect.left)); + const wPx: number = Math.max( + uW, + e.pageX - (window.scrollX + rect.left), + ); let wUnits: number = GetDashboardComponentWidthInDashboardUnits( p.totalCurrentDashboardWidthInPx, wPx, ); wUnits = Math.max(c.minWidthInDashboardUnits, wUnits); - wUnits = Math.min(DefaultDashboardSize.widthInDashboardUnits, wUnits); + wUnits = Math.min( + DefaultDashboardSize.widthInDashboardUnits, + wUnits, + ); s.liveWidth = wUnits; - const newWidthPx: number = - uW * wUnits + (SpaceBetweenUnitsInPx - 2) * (wUnits - 1); + const newWidthPx: number = uW * wUnits + g * (wUnits - 1); el.style.width = `${newWidthPx}px`; } if (s.mode === "resize-h" || s.mode === "resize-corner") { - const hPx: number = Math.max(uH, e.pageY - (window.scrollY + rect.top)); + const hPx: number = Math.max( + uH, + e.pageY - (window.scrollY + rect.top), + ); let hUnits: number = GetDashboardComponentHeightInDashboardUnits( p.totalCurrentDashboardWidthInPx, hPx, @@ -210,8 +203,7 @@ const DashboardBaseComponentElement: FunctionComponent = ( hUnits = Math.max(c.minHeightInDashboardUnits, hUnits); s.liveHeight = hUnits; - const newHeightPx: number = - uH * hUnits + SpaceBetweenUnitsInPx * (hUnits - 1); + const newHeightPx: number = uH * hUnits + g * (hUnits - 1); el.style.height = `${newHeightPx}px`; } @@ -233,8 +225,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( overlay.style.inset = "0"; overlay.style.zIndex = "9999"; overlay.style.cursor = cursor; - // Transparent but captures all pointer events, preventing - // underlying components from firing mouseEnter/mouseLeave. overlay.style.background = "transparent"; document.body.appendChild(overlay); overlayRef.current = overlay; @@ -253,7 +243,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( if (el) { el.style.transform = ""; el.style.zIndex = ""; - // Width/height are cleared so React's values take over after commit el.style.width = ""; el.style.height = ""; } @@ -268,7 +257,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( const c: DashboardBaseComponent = latestComponent.current; const p: ComponentProps = latestProps.current; - // Build the final component — only the fields that changed const updated: DashboardBaseComponent = { ...c }; let changed: boolean = false; @@ -297,7 +285,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( } } - // Clean up if component unmounts while dragging useEffect(() => { return () => { window.removeEventListener("mousemove", onMouseMove); @@ -333,7 +320,6 @@ const DashboardBaseComponentElement: FunctionComponent = ( sessionRef.current = session; setIsDragging(true); - // Show initial tooltip value updateTooltip(session); window.addEventListener("mousemove", onMouseMove); @@ -379,20 +365,10 @@ const DashboardBaseComponentElement: FunctionComponent = ( const className: string = [ "relative rounded-xl bg-white border overflow-hidden", - `col-span-${widthOfComponent} row-span-${heightOfComponent}`, borderClass, extraClass, ].join(" "); - // ── Computed sizes (React-controlled, used when NOT dragging) ── - const componentHeight: number = - unitH * heightOfComponent + - SpaceBetweenUnitsInPx * (heightOfComponent - 1); - - const componentWidth: number = - unitW * widthOfComponent + - (SpaceBetweenUnitsInPx - 2) * (widthOfComponent - 1); - // ── Render ──────────────────────────────────────────────── const getMoveHandle: GetReactElementFunction = (): ReactElement => { @@ -522,15 +498,13 @@ const DashboardBaseComponentElement: FunctionComponent = (
` and `AnalyticsDatabaseService`. - -Add `TableBillingAccessControl` to both models following the pattern in existing analytics models to enable plan-based billing constraints on profile ingestion/querying. - -### 2.4 Data Migration - -Follow the migration pattern from `Worker/DataMigrations/AddRetentionDateAndSkipIndexesToTelemetryTables.ts`: -- Add `retentionDate` column with TTL expression: `retentionDate DELETE` -- Add skip indexes: `bloom_filter` on `traceId`, `profileId`, `stacktraceHash`; `set` on `profileType` -- Apply `ZSTD(3)` codec on `stacktrace` and `labels` columns (high compression benefit) -- Default retention: 15 days (matching existing telemetry defaults) - -### Estimated Effort: 2-3 weeks +**Implemented in:** +- Profile model: `Common/Models/AnalyticsModels/Profile.ts` +- ProfileSample model: `Common/Models/AnalyticsModels/ProfileSample.ts` +- ProfileService: `Common/Server/Services/ProfileService.ts` +- ProfileSampleService: `Common/Server/Services/ProfileSampleService.ts` +- API routes registered in `App/FeatureSet/BaseAPI/Index.ts` --- -## Phase 3: Ingestion Service +## Phase 3: Ingestion Service ✅ COMPLETE -**Goal**: Process OTLP Profiles payloads and write to ClickHouse. +**Status**: Full OTLP Profiles ingestion is implemented including dictionary denormalization, inline frame handling, mixed-runtime stack support, trace/span correlation via Link table, stacktrace hashing (SHA256), batch processing, and graceful error handling. -### 3.1 Create Ingest Service - -Create `Telemetry/Services/OtelProfilesIngestService.ts` extending `OtelIngestBaseService`: - -```typescript -class OtelProfilesIngestService extends OtelIngestBaseService { - // Entry point - async ingestProfiles(request: ExportProfilesServiceRequest): Promise; - - // Denormalize OTLP profile data: - // 1. Resolve string_table references - // 2. Resolve function/location/mapping references - // 3. Build fully-qualified stack frames per sample - // 4. Extract trace_id/span_id for correlation - // 5. Buffer and batch-insert into ClickHouse - async processProfile(profile: ProfileContainer, resource: Resource): Promise; - - // Flush buffer (batch size: 500 samples) - async flushProfilesBuffer(): Promise; -} -``` - -### 3.2 Create Queue Service - -Create `Telemetry/Services/Queue/ProfilesQueueService.ts`: -- Add `TelemetryType.Profiles` enum value -- Register queue handler in `Telemetry/Jobs/TelemetryIngest/ProcessTelemetry.ts` -- Batch size: 500 (start conservative, tune later) - -### 3.3 Key Implementation Details - -**Denormalization logic** (the hardest part of this phase): - -The OTLP Profile message uses dictionary tables for compression. **The dictionary is batch-scoped** — it lives on the `ProfilesData` message, not on individual `Profile` messages. The ingestion service must pass the dictionary when processing each profile. - -``` -dictionary = profilesData.dictionary // batch-level dictionary - -For each resourceProfiles in profilesData.resource_profiles: - For each scopeProfiles in resourceProfiles.scope_profiles: - For each profile in scopeProfiles.profiles: - For each sample in profile.sample: - stack = dictionary.stack_table[sample.stack_index] - For each location_index in stack.location_indices: - location = dictionary.location_table[location_index] - // Handle INLINE FRAMES: location.lines is repeated - For each line in location.lines: - function = dictionary.function_table[line.function_index] - function_name = dictionary.string_table[function.name_strindex] - system_name = dictionary.string_table[function.system_name_strindex] // mangled name - file_name = dictionary.string_table[function.filename_strindex] - frame_type = attributes[profile.frame.type] // kernel, native, jvm, etc. - frame = "${function_name}@${file_name}:${line.line}" - Build stacktrace array from all frames (including inlined) - Compute stacktrace_hash = SHA256(stacktrace) - - // Resolve trace correlation from Link table - link = dictionary.link_table[sample.link_index] - trace_id = link.trace_id - span_id = link.span_id - - // Note: sample.timestamps_unix_nano is REPEATED (multiple timestamps per sample) - // Use first timestamp as sample time, store all if needed - - Extract value from sample.values[type_index] - Write denormalized row to buffer -``` - -**Mixed-runtime stacks:** -The eBPF agent produces stacks that cross kernel/native/managed boundaries (e.g., kernel → libc → JVM → application Java code). Each frame has a `profile.frame.type` attribute. Store this per-frame in the `frameTypes` array column for proper rendering. - -**Unsymbolized frames:** -Not all frames will be symbolized at ingestion time (especially native/kernel frames from eBPF). Store the mapping `build_id` attributes (`process.executable.build_id.gnu`, `.go`, `.htlhash`) so frames can be symbolized later when debug info becomes available. See Phase 6 for symbolization pipeline. - -**pprof interoperability:** -If `original_payload_format` is set (e.g., `pprofext`), store the `original_payload` bytes for lossless re-export. The OTLP Profiles format supports round-trip conversion to/from pprof with no information loss. - -### Estimated Effort: 2-3 weeks +**Implemented in:** +- Ingest service (835 lines): `Telemetry/Services/OtelProfilesIngestService.ts` +- Queue service: `Telemetry/Services/Queue/ProfilesQueueService.ts` +- Queue handler: `Telemetry/Jobs/TelemetryIngest/ProcessTelemetry.ts` --- -## Phase 4: Query API +## Phase 4: Query API ✅ MOSTLY COMPLETE -**Goal**: Expose APIs for querying and aggregating profile data. +**Status**: Flamegraph aggregation and function list queries are implemented with tree-building algorithm, filtering by projectId/profileId/serviceId/time ranges/profile type, and a 50K sample limit per query. -### 4.1 Core Query Endpoints +**Implemented in:** +- ProfileAggregationService (417 lines): `Common/Server/Services/ProfileAggregationService.ts` + - `getFlamegraph()` — Aggregated flamegraph tree from samples + - `getFunctionList()` — Top functions by selfValue, totalValue, or sampleCount +- CRUD routes for profile/profile-sample: `App/FeatureSet/BaseAPI/Index.ts` -Add to the telemetry API router: +### Remaining Items -| Endpoint | Purpose | -|----------|---------| -| `GET /profiles` | List profiles with filters (service, time range, profile type) | -| `GET /profiles/:profileId` | Get profile metadata | -| `GET /profiles/:profileId/flamegraph` | Aggregated flamegraph data for a single profile | -| `GET /profiles/aggregate/flamegraph` | Aggregated flamegraph across multiple profiles (time range) | -| `GET /profiles/function-list` | Top functions by self/total time | -| `GET /profiles/diff` | Diff flamegraph between two time ranges | - -### 4.2 Flamegraph Aggregation Query - -The core query for flamegraph rendering in ClickHouse: - -```sql -SELECT - stacktrace, - SUM(value) as total_value -FROM profile_sample -WHERE projectId = {projectId} - AND serviceId = {serviceId} - AND time BETWEEN {startTime} AND {endTime} - AND profileType = {profileType} -GROUP BY stacktrace -ORDER BY total_value DESC -LIMIT 10000 -``` - -The API layer then builds a tree structure from flat stacktraces for the frontend flamegraph component. - -### 4.3 Cross-Signal Correlation Queries - -Leverage `traceId`/`spanId` columns for correlation: - -```sql --- Get profile samples for a specific trace -SELECT stacktrace, SUM(value) as total_value -FROM profile_sample -WHERE projectId = {projectId} - AND traceId = {traceId} -GROUP BY stacktrace - --- Get profile samples for a specific span -SELECT stacktrace, SUM(value) as total_value -FROM profile_sample -WHERE projectId = {projectId} - AND spanId = {spanId} -GROUP BY stacktrace -``` - -This enables a "View Profile" button on the trace detail page. - -### Estimated Effort: 2 weeks +- **Diff flamegraph endpoint** — `GET /profiles/diff` for comparing two time ranges not yet implemented +- **Cross-signal correlation queries** — Dedicated endpoints for querying profiles by `traceId`/`spanId` (e.g., "View Profile" button on trace detail page) --- -## Phase 5: Frontend — Profiles UI +## Phase 5: Frontend — Profiles UI ✅ MOSTLY COMPLETE -**Goal**: Build the profiles exploration and visualization UI. +**Status**: Core pages (listing, detail view, layout, side menu, documentation) and key components (flamegraph, function list, profiles table) are implemented. -### 5.1 New Pages & Routes +**Implemented in:** +- Pages: `App/FeatureSet/Dashboard/src/Pages/Profiles/` (Index, View/Index, Layout, SideMenu, Documentation) +- Components: `App/FeatureSet/Dashboard/src/Components/Profiles/` (ProfileFlamegraph, ProfileFunctionList, ProfileTable) -Add to `App/FeatureSet/Dashboard/src/`: +### Remaining Items -- `Pages/Profiles/ProfileList.tsx` — List/search profiles by service, time range, type -- `Pages/Profiles/ProfileDetail.tsx` — Single profile detail view -- `Routes/ProfilesRoutes.tsx` — Route definitions - -### 5.2 Core Components - -| Component | Purpose | -|-----------|---------| -| `Components/Profiles/FlameGraph.tsx` | Interactive flamegraph (CPU/memory/alloc). Consider using an existing open-source flamegraph library (e.g., `speedscope` or `d3-flame-graph`) | -| `Components/Profiles/FunctionList.tsx` | Table of functions sorted by self/total time with search | -| `Components/Profiles/ProfileTypeSelector.tsx` | Dropdown to select profile type (CPU, heap, goroutine, etc.) | -| `Components/Profiles/DiffFlameGraph.tsx` | Side-by-side or differential flamegraph comparing two time ranges | -| `Components/Profiles/ProfileTimeline.tsx` | Timeline showing profile sample density over time | - -**Frame type color coding:** -Mixed-runtime stacks from the eBPF agent contain frames from different runtimes (kernel, native, JVM, CPython, Go, V8, etc.). The flamegraph component should color-code frames by their `profile.frame.type` attribute so users can visually distinguish application code from kernel/native/runtime internals. Suggested palette: -- Kernel frames: red/orange -- Native (C/C++/Rust): blue -- JVM/Go/V8/CPython/Ruby: green shades (per runtime) - -### 5.3 Sidebar Navigation - -Create `Pages/Profiles/SideMenu.tsx` following the existing pattern (see `Pages/Traces/SideMenu.tsx`, `Pages/Metrics/SideMenu.tsx`, `Pages/Logs/SideMenu.tsx`): -- Main section: "Profiles" → PageMap.PROFILES -- Documentation section: Link to PROFILES_DOCUMENTATION route - -Add "Profiles" entry to the main dashboard navigation sidebar. - -### 5.4 Cross-Signal Integration - -- **Trace Detail Page**: Add a "Profile" tab/button on `TraceExplorer.tsx` that links to the flamegraph filtered by `traceId`. -- **Span Detail**: When viewing a span, show an inline flamegraph if profile samples exist for that `spanId`. -- **Service Overview**: Add a "Profiles" tab on the service detail page showing aggregated flamegraphs. - -### Estimated Effort: 3-4 weeks +- **DiffFlameGraph component** — Side-by-side or differential flamegraph comparing two time ranges +- **ProfileTimeline component** — Timeline showing profile sample density over time +- **ProfileTypeSelector component** — Dropdown to select profile type (CPU, heap, goroutine, etc.) +- **Frame type color coding** — Color-code flamegraph frames by `profile.frame.type` (kernel=red/orange, native=blue, managed=green shades) +- **5.4 Cross-Signal Integration**: + - Trace Detail Page: Add "Profile" tab/button on `TraceExplorer.tsx` linking to flamegraph by `traceId` + - Span Detail: Inline flamegraph when profile samples exist for a `spanId` + - Service Overview: "Profiles" tab on service detail page with aggregated flamegraphs --- @@ -538,19 +195,17 @@ Add `Telemetry/Docs/profileData.example.json` with a sample OTLP Profiles payloa ## Summary Timeline -| Phase | Description | Effort | Dependencies | -|-------|-------------|--------|--------------| -| 1 | Protocol & Ingestion Layer | 1-2 weeks | None | -| 2 | Data Model & ClickHouse Storage | 2-3 weeks | Phase 1 | -| 3 | Ingestion Service | 2-3 weeks | Phase 1, 2 | -| 4 | Query API | 2 weeks | Phase 2, 3 | -| 5 | Frontend — Profiles UI | 3-4 weeks | Phase 4 | -| 6 | Production Hardening (incl. symbolization, alerting, conformance) | 3-4 weeks | Phase 5 | -| 7 | Documentation & Launch | 1 week | Phase 6 | +| Phase | Description | Status | +|-------|-------------|--------| +| 1 | Protocol & Ingestion Layer | ✅ Complete (gRPC, OTel Collector config, Helm chart remaining) | +| 2 | Data Model & ClickHouse Storage | ✅ Complete | +| 3 | Ingestion Service | ✅ Complete | +| 4 | Query API | ✅ Mostly complete (diff flamegraph, cross-signal endpoints remaining) | +| 5 | Frontend — Profiles UI | ✅ Mostly complete (diff view, timeline, color coding, cross-signal integration remaining) | +| 6 | Production Hardening | ❌ Not started | +| 7 | Documentation & Launch | ❌ Not started | -**Total estimated effort: 14-21 weeks** (with parallelization of phases 4+5, closer to 11-16 weeks) - -**Suggested MVP scope (Phases 1-5):** Ship ingestion + storage + basic flamegraph UI first (~9-14 weeks). Symbolization, alerting integration, and pprof export can follow as iterative improvements. +**Remaining work is primarily:** Phase 1 gaps (gRPC/Helm), Phase 4-5 advanced features (diff flamegraphs, cross-signal integration, frame type color coding), and all of Phases 6-7 (symbolization, alerting, pprof export, conformance, docs). ---