mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Add ProfileTable component for displaying profiling data
- Implemented ProfileTable component to visualize profiles with advanced filtering options. - Integrated telemetry services and attributes loading for dynamic filtering. - Added error handling and loading states for improved user experience. feat: Create ProfileUtil for stack frame parsing and formatting - Introduced ProfileUtil class with methods for frame type color coding and stack frame parsing. - Added utility functions for formatting profile values based on units. docs: Add documentation for telemetry profiles integration - Created comprehensive guide on sending continuous profiling data to OneUptime. - Included supported profile types, setup instructions, and instrumentation examples. feat: Implement ProfileAggregationService for flamegraph and function list retrieval - Developed ProfileAggregationService to aggregate profile samples and generate flamegraphs. - Added functionality to retrieve top functions based on various metrics. feat: Define MonitorStepProfileMonitor interface for profile monitoring - Created MonitorStepProfileMonitor interface and utility for building queries based on monitoring parameters. test: Add example OTLP profiles payload for testing - Included example JSON payload for OTLP profiles to assist in testing and integration.
This commit is contained in:
@@ -56,34 +56,61 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-4 mt-4 mb-3 rounded-xl bg-white border border-gray-100"
|
||||
className="mx-4 mt-3 mb-2 rounded-lg bg-white border border-gray-100"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.03)",
|
||||
"0 1px 2px 0 rgba(0, 0, 0, 0.03)",
|
||||
}}
|
||||
>
|
||||
{/* Top row: Dashboard name + action buttons */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-base font-semibold text-gray-800 truncate">
|
||||
{/* Single row: Dashboard name + time range + variables + action buttons */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-wrap">
|
||||
<h1 className="text-sm font-semibold text-gray-800 truncate">
|
||||
{props.dashboardName}
|
||||
</h1>
|
||||
{isEditMode && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mr-1.5"></span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mr-1"></span>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
{hasComponents && !isEditMode && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium text-gray-500 bg-gray-50">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-gray-400">
|
||||
{props.dashboardViewConfig.components.length} widget
|
||||
{props.dashboardViewConfig.components.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Time range + variables inline (only when components exist and not in edit mode) */}
|
||||
{hasComponents && !isEditMode && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-200"></div>
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Template variables */}
|
||||
{props.variables &&
|
||||
props.variables.length > 0 &&
|
||||
props.onVariableValueChange && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-200"></div>
|
||||
<DashboardVariableSelector
|
||||
variables={props.variables}
|
||||
onVariableValueChange={props.onVariableValueChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Refreshing indicator */}
|
||||
{props.isRefreshing &&
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-blue-600">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
Refreshing
|
||||
</span>
|
||||
@@ -91,7 +118,7 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
|
||||
{!isSaving && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<MoreMenu menuIcon={IconProp.Add} text="Add Widget">
|
||||
@@ -157,7 +184,7 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
</MoreMenu>
|
||||
|
||||
<div className="w-px h-6 bg-gray-200 mx-1"></div>
|
||||
<div className="w-px h-5 bg-gray-200 mx-0.5"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Check}
|
||||
@@ -220,7 +247,7 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
tooltip="Full Screen"
|
||||
/>
|
||||
|
||||
<div className="w-px h-5 bg-gray-200 mx-0.5"></div>
|
||||
<div className="w-px h-4 bg-gray-200 mx-0.5"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Pencil}
|
||||
@@ -242,35 +269,6 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Time range + variables (only when components exist and not in edit mode) */}
|
||||
{hasComponents && !isEditMode && (
|
||||
<div className="flex items-center gap-3 px-5 pb-4 pt-0 flex-wrap border-t border-gray-50">
|
||||
<div className="pt-3">
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template variables */}
|
||||
{props.variables &&
|
||||
props.variables.length > 0 &&
|
||||
props.onVariableValueChange && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-gray-200 mt-3"></div>
|
||||
<div className="pt-3">
|
||||
<DashboardVariableSelector
|
||||
variables={props.variables}
|
||||
onVariableValueChange={props.onVariableValueChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCancelModal ? (
|
||||
<ConfirmModal
|
||||
title={`Are you sure?`}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import ProfileUtil, { ParsedStackFrame } from "../../Utils/ProfileUtil";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
|
||||
export interface ProfileFlamegraphProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
interface FlamegraphNode {
|
||||
name: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
frameType: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
children: Map<string, FlamegraphNode>;
|
||||
}
|
||||
|
||||
interface TooltipData {
|
||||
name: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const ProfileFlamegraph: FunctionComponent<ProfileFlamegraphProps> = (
|
||||
props: ProfileFlamegraphProps,
|
||||
): ReactElement => {
|
||||
const [samples, setSamples] = useState<Array<ProfileSample>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [zoomStack, setZoomStack] = useState<Array<FlamegraphNode>>([]);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const loadSamples: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const result: ListResult<ProfileSample> =
|
||||
await AnalyticsModelAPI.getList({
|
||||
modelType: ProfileSample,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
profileId: props.profileId,
|
||||
},
|
||||
select: {
|
||||
stacktrace: true,
|
||||
frameTypes: true,
|
||||
value: true,
|
||||
profileType: true,
|
||||
},
|
||||
limit: 10000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
value: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
setSamples(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSamples();
|
||||
}, [props.profileId]);
|
||||
|
||||
const rootNode: FlamegraphNode = useMemo(() => {
|
||||
const root: FlamegraphNode = {
|
||||
name: "root",
|
||||
fileName: "",
|
||||
lineNumber: 0,
|
||||
frameType: "",
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: new Map<string, FlamegraphNode>(),
|
||||
};
|
||||
|
||||
for (const sample of samples) {
|
||||
const stacktrace: Array<string> = sample.stacktrace || [];
|
||||
const frameTypes: Array<string> = sample.frameTypes || [];
|
||||
const value: number = sample.value || 0;
|
||||
|
||||
let currentNode: FlamegraphNode = root;
|
||||
root.totalValue += value;
|
||||
|
||||
// Walk from root to leaf (stacktrace is ordered root-to-leaf)
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: string = stacktrace[i]!;
|
||||
const frameType: string =
|
||||
i < frameTypes.length ? frameTypes[i]! : "unknown";
|
||||
|
||||
let child: FlamegraphNode | undefined =
|
||||
currentNode.children.get(frame);
|
||||
|
||||
if (!child) {
|
||||
const parsed: ParsedStackFrame = ProfileUtil.parseStackFrame(frame);
|
||||
child = {
|
||||
name: parsed.functionName,
|
||||
fileName: parsed.fileName,
|
||||
lineNumber: parsed.lineNumber,
|
||||
frameType,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: new Map<string, FlamegraphNode>(),
|
||||
};
|
||||
currentNode.children.set(frame, child);
|
||||
}
|
||||
|
||||
child.totalValue += value;
|
||||
|
||||
// Last frame in the stack is the leaf -- add self value
|
||||
if (i === stacktrace.length - 1) {
|
||||
child.selfValue += value;
|
||||
}
|
||||
|
||||
currentNode = child;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}, [samples]);
|
||||
|
||||
const activeRoot: FlamegraphNode = useMemo(() => {
|
||||
if (zoomStack.length > 0) {
|
||||
return zoomStack[zoomStack.length - 1]!;
|
||||
}
|
||||
return rootNode;
|
||||
}, [rootNode, zoomStack]);
|
||||
|
||||
const handleClickNode: (node: FlamegraphNode) => void = useCallback(
|
||||
(node: FlamegraphNode): void => {
|
||||
if (node.children.size > 0) {
|
||||
setZoomStack((prev: Array<FlamegraphNode>) => {
|
||||
return [...prev, node];
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleZoomOut: () => void = useCallback((): void => {
|
||||
setZoomStack((prev: Array<FlamegraphNode>) => {
|
||||
return prev.slice(0, prev.length - 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetZoom: () => void = useCallback((): void => {
|
||||
setZoomStack([]);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter: (
|
||||
node: FlamegraphNode,
|
||||
event: React.MouseEvent,
|
||||
) => void = useCallback(
|
||||
(node: FlamegraphNode, event: React.MouseEvent): void => {
|
||||
setTooltip({
|
||||
name: node.name,
|
||||
fileName: node.fileName,
|
||||
selfValue: node.selfValue,
|
||||
totalValue: node.totalValue,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave: () => void = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadSamples();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No profile samples found for this profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderNode: (
|
||||
node: FlamegraphNode,
|
||||
parentTotal: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
) => ReactElement | null = (
|
||||
node: FlamegraphNode,
|
||||
parentTotal: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
): ReactElement | null => {
|
||||
if (widthFraction < 0.005) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgColor: string = ProfileUtil.getFrameTypeColor(node.frameType);
|
||||
const percentage: number =
|
||||
parentTotal > 0 ? (node.totalValue / parentTotal) * 100 : 0;
|
||||
|
||||
const children: Array<FlamegraphNode> = Array.from(
|
||||
node.children.values(),
|
||||
).sort((a: FlamegraphNode, b: FlamegraphNode) => {
|
||||
return b.totalValue - a.totalValue;
|
||||
});
|
||||
|
||||
let childOffset: number = 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${node.name}-${depth}-${offsetFraction}`}>
|
||||
<div
|
||||
className={`absolute h-6 border border-white/30 cursor-pointer overflow-hidden text-xs text-white leading-6 px-1 truncate ${bgColor} hover:opacity-80`}
|
||||
style={{
|
||||
left: `${offsetFraction * 100}%`,
|
||||
width: `${widthFraction * 100}%`,
|
||||
top: `${depth * 26}px`,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleClickNode(node);
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent) => {
|
||||
handleMouseEnter(node, e);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`${node.name} (${percentage.toFixed(1)}%)`}
|
||||
>
|
||||
{widthFraction > 0.03 ? node.name : ""}
|
||||
</div>
|
||||
{children.map((child: FlamegraphNode) => {
|
||||
const childWidth: number =
|
||||
node.totalValue > 0
|
||||
? (child.totalValue / node.totalValue) * widthFraction
|
||||
: 0;
|
||||
const currentOffset: number = offsetFraction + childOffset;
|
||||
childOffset += childWidth;
|
||||
|
||||
return renderNode(
|
||||
child,
|
||||
node.totalValue,
|
||||
depth + 1,
|
||||
currentOffset,
|
||||
childWidth,
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const getMaxDepth: (node: FlamegraphNode, depth: number) => number = (
|
||||
node: FlamegraphNode,
|
||||
depth: number,
|
||||
): number => {
|
||||
let max: number = depth;
|
||||
for (const child of node.children.values()) {
|
||||
const childDepth: number = getMaxDepth(child, depth + 1);
|
||||
if (childDepth > max) {
|
||||
max = childDepth;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const maxDepth: number = getMaxDepth(activeRoot, 0);
|
||||
const height: number = (maxDepth + 1) * 26 + 10;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{zoomStack.length > 0 && (
|
||||
<div className="mb-3 flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleResetZoom}
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Zoomed into: {activeRoot.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<span className="font-medium">Frame Types:</span>
|
||||
{[
|
||||
"kernel",
|
||||
"native",
|
||||
"jvm",
|
||||
"cpython",
|
||||
"go",
|
||||
"v8js",
|
||||
"unknown",
|
||||
].map((type: string) => {
|
||||
return (
|
||||
<span key={type} className="flex items-center space-x-1">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded ${ProfileUtil.getFrameTypeColor(type)}`}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-x-auto border border-gray-200 rounded bg-white"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{renderNode(activeRoot, activeRoot.totalValue, 0, 0, 1)}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 bg-gray-900 text-white text-xs rounded px-3 py-2 pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${tooltip.x + 12}px`,
|
||||
top: `${tooltip.y + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{tooltip.name}</div>
|
||||
{tooltip.fileName && (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
Self: {tooltip.selfValue.toLocaleString()}
|
||||
</div>
|
||||
<div>Total: {tooltip.totalValue.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileFlamegraph;
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import ProfileUtil, { ParsedStackFrame } from "../../Utils/ProfileUtil";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
|
||||
export interface ProfileFunctionListProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
interface FunctionRow {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
type SortField =
|
||||
| "functionName"
|
||||
| "fileName"
|
||||
| "selfValue"
|
||||
| "totalValue"
|
||||
| "sampleCount";
|
||||
|
||||
const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
props: ProfileFunctionListProps,
|
||||
): ReactElement => {
|
||||
const [samples, setSamples] = useState<Array<ProfileSample>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [sortField, setSortField] = useState<SortField>("selfValue");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const loadSamples: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const result: ListResult<ProfileSample> =
|
||||
await AnalyticsModelAPI.getList({
|
||||
modelType: ProfileSample,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
profileId: props.profileId,
|
||||
},
|
||||
select: {
|
||||
stacktrace: true,
|
||||
frameTypes: true,
|
||||
value: true,
|
||||
profileType: true,
|
||||
},
|
||||
limit: 10000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
value: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
setSamples(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSamples();
|
||||
}, [props.profileId]);
|
||||
|
||||
const functionRows: Array<FunctionRow> = useMemo(() => {
|
||||
const functionMap: Map<
|
||||
string,
|
||||
{
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
for (const sample of samples) {
|
||||
const stacktrace: Array<string> = sample.stacktrace || [];
|
||||
const value: number = sample.value || 0;
|
||||
|
||||
const seenInThisSample: Set<string> = new Set<string>();
|
||||
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: string = stacktrace[i]!;
|
||||
const parsed: ParsedStackFrame = ProfileUtil.parseStackFrame(frame);
|
||||
const key: string = `${parsed.functionName}@${parsed.fileName}`;
|
||||
|
||||
let entry: FunctionRow | undefined = functionMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
functionName: parsed.functionName,
|
||||
fileName: parsed.fileName,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
sampleCount: 0,
|
||||
};
|
||||
functionMap.set(key, entry);
|
||||
}
|
||||
|
||||
// Only add total value once per sample (avoid double-counting recursive calls)
|
||||
if (!seenInThisSample.has(key)) {
|
||||
entry.totalValue += value;
|
||||
entry.sampleCount += 1;
|
||||
seenInThisSample.add(key);
|
||||
}
|
||||
|
||||
// Self value is only for the leaf frame
|
||||
if (i === stacktrace.length - 1) {
|
||||
entry.selfValue += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(functionMap.values());
|
||||
}, [samples]);
|
||||
|
||||
const sortedRows: Array<FunctionRow> = useMemo(() => {
|
||||
const rows: Array<FunctionRow> = [...functionRows];
|
||||
|
||||
rows.sort((a: FunctionRow, b: FunctionRow) => {
|
||||
let aVal: string | number = a[sortField];
|
||||
let bVal: string | number = b[sortField];
|
||||
|
||||
if (typeof aVal === "string") {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = (bVal as string).toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return sortDirection === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return sortDirection === "asc" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}, [functionRows, sortField, sortDirection]);
|
||||
|
||||
const handleSort: (field: SortField) => void = useCallback(
|
||||
(field: SortField): void => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((prev: "asc" | "desc") => {
|
||||
return prev === "asc" ? "desc" : "asc";
|
||||
});
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("desc");
|
||||
}
|
||||
},
|
||||
[sortField],
|
||||
);
|
||||
|
||||
const getSortIndicator: (field: SortField) => string = useCallback(
|
||||
(field: SortField): string => {
|
||||
if (field !== sortField) {
|
||||
return "";
|
||||
}
|
||||
return sortDirection === "asc" ? " \u2191" : " \u2193";
|
||||
},
|
||||
[sortField, sortDirection],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadSamples();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No profile samples found for this profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-sm text-left border border-gray-200 rounded">
|
||||
<thead className="bg-gray-50 text-gray-700 font-medium">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("functionName");
|
||||
}}
|
||||
>
|
||||
Function{getSortIndicator("functionName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("fileName");
|
||||
}}
|
||||
>
|
||||
File{getSortIndicator("fileName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("selfValue");
|
||||
}}
|
||||
>
|
||||
Self Value{getSortIndicator("selfValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("totalValue");
|
||||
}}
|
||||
>
|
||||
Total Value{getSortIndicator("totalValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("sampleCount");
|
||||
}}
|
||||
>
|
||||
Samples{getSortIndicator("sampleCount")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row: FunctionRow, index: number) => {
|
||||
return (
|
||||
<tr
|
||||
key={`${row.functionName}-${row.fileName}-${index}`}
|
||||
className="border-t border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs truncate max-w-xs">
|
||||
{row.functionName}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs truncate max-w-xs">
|
||||
{row.fileName || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.selfValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.totalValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.sampleCount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileFunctionList;
|
||||
@@ -0,0 +1,342 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import AnalyticsModelTable from "Common/UI/Components/ModelTable/AnalyticsModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import API from "Common/Utils/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
|
||||
export interface ComponentProps {
|
||||
modelId?: ObjectID | undefined;
|
||||
profileQuery?: Query<Profile> | undefined;
|
||||
isMinimalTable?: boolean | undefined;
|
||||
noItemsMessage?: string | undefined;
|
||||
}
|
||||
|
||||
const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID | undefined = props.modelId;
|
||||
|
||||
const [attributes, setAttributes] = React.useState<Array<string>>([]);
|
||||
const [attributesLoaded, setAttributesLoaded] =
|
||||
React.useState<boolean>(false);
|
||||
const [attributesLoading, setAttributesLoading] =
|
||||
React.useState<boolean>(false);
|
||||
const [attributesError, setAttributesError] = React.useState<string>("");
|
||||
|
||||
const [isPageLoading, setIsPageLoading] = React.useState<boolean>(true);
|
||||
const [pageError, setPageError] = React.useState<string>("");
|
||||
|
||||
const [telemetryServices, setServices] = React.useState<Array<Service>>([]);
|
||||
|
||||
const [areAdvancedFiltersVisible, setAreAdvancedFiltersVisible] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const query: Query<Profile> = React.useMemo(() => {
|
||||
const baseQuery: Query<Profile> = {
|
||||
...(props.profileQuery || {}),
|
||||
};
|
||||
|
||||
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
|
||||
|
||||
if (projectId) {
|
||||
baseQuery.projectId = projectId;
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
baseQuery.serviceId = modelId;
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}, [props.profileQuery, modelId]);
|
||||
|
||||
const loadServices: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setIsPageLoading(true);
|
||||
setPageError("");
|
||||
|
||||
const telemetryServicesResponse: ListResult<Service> =
|
||||
await ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
setServices(telemetryServicesResponse.data || []);
|
||||
} catch (err) {
|
||||
setPageError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsPageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAttributes: PromiseVoidFunction = async (): Promise<void> => {
|
||||
if (attributesLoading || attributesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAttributesLoading(true);
|
||||
setAttributesError("");
|
||||
|
||||
const attributeResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/get-attributes",
|
||||
),
|
||||
data: {},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (attributeResponse instanceof HTTPErrorResponse) {
|
||||
throw attributeResponse;
|
||||
}
|
||||
|
||||
const fetchedAttributes: Array<string> = (attributeResponse.data[
|
||||
"attributes"
|
||||
] || []) as Array<string>;
|
||||
setAttributes(fetchedAttributes);
|
||||
setAttributesLoaded(true);
|
||||
} catch (err) {
|
||||
setAttributes([]);
|
||||
setAttributesLoaded(false);
|
||||
setAttributesError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setAttributesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServices().catch((err: Error) => {
|
||||
setPageError(API.getFriendlyErrorMessage(err as Error));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAdvancedFiltersToggle: (show: boolean) => void = (
|
||||
show: boolean,
|
||||
): void => {
|
||||
setAreAdvancedFiltersVisible(show);
|
||||
|
||||
if (show && !attributesLoaded && !attributesLoading) {
|
||||
void loadAttributes();
|
||||
}
|
||||
};
|
||||
|
||||
if (isPageLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{pageError && (
|
||||
<div className="mb-4">
|
||||
<ErrorMessage
|
||||
message={`We couldn't load telemetry services. ${pageError}`}
|
||||
onRefreshClick={() => {
|
||||
void loadServices();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{areAdvancedFiltersVisible && attributesError && (
|
||||
<div className="mb-4">
|
||||
<ErrorMessage
|
||||
message={`We couldn't load profile attributes. ${attributesError}`}
|
||||
onRefreshClick={() => {
|
||||
setAttributesLoaded(false);
|
||||
void loadAttributes();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded">
|
||||
<AnalyticsModelTable<Profile>
|
||||
userPreferencesKey="profile-table"
|
||||
disablePagination={props.isMinimalTable}
|
||||
modelType={Profile}
|
||||
id="profiles-table"
|
||||
isDeleteable={false}
|
||||
isEditable={false}
|
||||
isCreateable={false}
|
||||
singularName="Profile"
|
||||
pluralName="Profiles"
|
||||
name="Profiles"
|
||||
isViewable={true}
|
||||
cardProps={
|
||||
props.isMinimalTable
|
||||
? undefined
|
||||
: {
|
||||
title: "Profiles",
|
||||
description:
|
||||
"Continuous profiling data from your services. Profiles help you understand CPU, memory, and allocation hotspots in your applications.",
|
||||
}
|
||||
}
|
||||
query={query}
|
||||
showViewIdButton={true}
|
||||
noItemsMessage={
|
||||
props.noItemsMessage
|
||||
? props.noItemsMessage
|
||||
: "No profiles found."
|
||||
}
|
||||
showRefreshButton={true}
|
||||
sortBy="startTime"
|
||||
sortOrder={SortOrder.Descending}
|
||||
onViewPage={(profile: Profile) => {
|
||||
return Promise.resolve(
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILE_VIEW]!,
|
||||
{
|
||||
modelId: profile.profileId!,
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
},
|
||||
type: FieldType.MultiSelectDropdown,
|
||||
filterDropdownOptions: telemetryServices.map(
|
||||
(service: Service) => {
|
||||
return {
|
||||
label: service.name!,
|
||||
value: service.id!.toString(),
|
||||
};
|
||||
},
|
||||
),
|
||||
title: "Service",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Profile Type",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
traceId: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Trace ID",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
type: FieldType.DateTime,
|
||||
title: "Start Time",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
attributes: true,
|
||||
},
|
||||
type: FieldType.JSON,
|
||||
title: "Attributes",
|
||||
jsonKeys: attributes,
|
||||
isAdvancedFilter: true,
|
||||
},
|
||||
]}
|
||||
onAdvancedFiltersToggle={handleAdvancedFiltersToggle}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
profileId: true,
|
||||
},
|
||||
title: "Profile ID",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
title: "Profile Type",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
},
|
||||
title: "Service",
|
||||
type: FieldType.Element,
|
||||
getElement: (profile: Profile): ReactElement => {
|
||||
const telemetryService: Service | undefined =
|
||||
telemetryServices.find((service: Service) => {
|
||||
return (
|
||||
service.id?.toString() ===
|
||||
profile.serviceId?.toString()
|
||||
);
|
||||
});
|
||||
|
||||
if (!telemetryService) {
|
||||
return <p>Unknown</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ServiceElement service={telemetryService} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
sampleCount: true,
|
||||
},
|
||||
title: "Samples",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
title: "Start Time",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTable;
|
||||
@@ -12,7 +12,7 @@ import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
|
||||
import ProfileTable from "../../Components/Profiles/ProfileTable";
|
||||
|
||||
const ProfilesPage: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
@@ -62,12 +62,7 @@ const ProfilesPage: FunctionComponent<PageComponentProps> = (
|
||||
return <TelemetryDocumentation telemetryType="profiles" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title="Profiles"
|
||||
value="Continuous profiling data from your services will appear here. Profiles help you understand CPU, memory, and allocation hotspots in your applications. Use the OpenTelemetry eBPF profiler agent or async-profiler with OTLP export to send profiling data."
|
||||
/>
|
||||
);
|
||||
return <ProfileTable />;
|
||||
};
|
||||
|
||||
export default ProfilesPage;
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
|
||||
import Tabs from "Common/UI/Components/Tabs/Tabs";
|
||||
import { Tab } from "Common/UI/Components/Tabs/Tab";
|
||||
import ProfileFlamegraph from "../../../Components/Profiles/ProfileFlamegraph";
|
||||
import ProfileFunctionList from "../../../Components/Profiles/ProfileFunctionList";
|
||||
|
||||
const ProfileViewPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const profileId: string = Navigation.getLastParamAsString(0);
|
||||
|
||||
const tabs: Array<Tab> = [
|
||||
{
|
||||
name: "Flamegraph",
|
||||
children: <ProfileFlamegraph profileId={profileId} />,
|
||||
},
|
||||
{
|
||||
name: "Function List",
|
||||
children: <ProfileFunctionList profileId={profileId} />,
|
||||
},
|
||||
];
|
||||
|
||||
const handleTabChange: (tab: Tab) => void = (_tab: Tab): void => {
|
||||
// Tab content is rendered by the Tabs component via children
|
||||
};
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={`Profile: ${profileId}`}
|
||||
value="Profile flamegraph visualization will be available here. This view will show CPU, memory, and allocation hotspots as an interactive flamegraph with function-level detail."
|
||||
/>
|
||||
<div>
|
||||
<Tabs tabs={tabs} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
148
App/FeatureSet/Dashboard/src/Utils/ProfileUtil.ts
Normal file
148
App/FeatureSet/Dashboard/src/Utils/ProfileUtil.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export interface ParsedStackFrame {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export default class ProfileUtil {
|
||||
public static getFrameTypeColor(frameType: string): string {
|
||||
const type: string = frameType.toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case "kernel":
|
||||
return "bg-red-500";
|
||||
case "native":
|
||||
return "bg-orange-500";
|
||||
case "jvm":
|
||||
return "bg-green-500";
|
||||
case "cpython":
|
||||
return "bg-blue-500";
|
||||
case "go":
|
||||
return "bg-cyan-500";
|
||||
case "v8js":
|
||||
return "bg-yellow-500";
|
||||
default:
|
||||
return "bg-gray-400";
|
||||
}
|
||||
}
|
||||
|
||||
public static getFrameTypeTextColor(frameType: string): string {
|
||||
const type: string = frameType.toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case "kernel":
|
||||
return "text-red-700";
|
||||
case "native":
|
||||
return "text-orange-700";
|
||||
case "jvm":
|
||||
return "text-green-700";
|
||||
case "cpython":
|
||||
return "text-blue-700";
|
||||
case "go":
|
||||
return "text-cyan-700";
|
||||
case "v8js":
|
||||
return "text-yellow-700";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
}
|
||||
}
|
||||
|
||||
public static getFrameTypeBgLight(frameType: string): string {
|
||||
const type: string = frameType.toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case "kernel":
|
||||
return "bg-red-400";
|
||||
case "native":
|
||||
return "bg-orange-400";
|
||||
case "jvm":
|
||||
return "bg-green-400";
|
||||
case "cpython":
|
||||
return "bg-blue-400";
|
||||
case "go":
|
||||
return "bg-cyan-400";
|
||||
case "v8js":
|
||||
return "bg-yellow-400";
|
||||
default:
|
||||
return "bg-gray-300";
|
||||
}
|
||||
}
|
||||
|
||||
public static parseStackFrame(frame: string): ParsedStackFrame {
|
||||
// Format: "function@file:line"
|
||||
const atIndex: number = frame.indexOf("@");
|
||||
|
||||
if (atIndex === -1) {
|
||||
return {
|
||||
functionName: frame,
|
||||
fileName: "",
|
||||
lineNumber: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const functionName: string = frame.substring(0, atIndex);
|
||||
const rest: string = frame.substring(atIndex + 1);
|
||||
|
||||
const lastColonIndex: number = rest.lastIndexOf(":");
|
||||
|
||||
if (lastColonIndex === -1) {
|
||||
return {
|
||||
functionName,
|
||||
fileName: rest,
|
||||
lineNumber: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const fileName: string = rest.substring(0, lastColonIndex);
|
||||
const lineStr: string = rest.substring(lastColonIndex + 1);
|
||||
const lineNumber: number = parseInt(lineStr, 10);
|
||||
|
||||
return {
|
||||
functionName,
|
||||
fileName,
|
||||
lineNumber: isNaN(lineNumber) ? 0 : lineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
public static formatProfileValue(value: number, unit: string): string {
|
||||
const lowerUnit: string = unit.toLowerCase();
|
||||
|
||||
if (lowerUnit === "nanoseconds" || lowerUnit === "ns") {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}s`;
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}ms`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}us`;
|
||||
}
|
||||
return `${value}ns`;
|
||||
}
|
||||
|
||||
if (lowerUnit === "bytes" || lowerUnit === "byte") {
|
||||
if (value >= 1_073_741_824) {
|
||||
return `${(value / 1_073_741_824).toFixed(2)} GB`;
|
||||
}
|
||||
if (value >= 1_048_576) {
|
||||
return `${(value / 1_048_576).toFixed(2)} MB`;
|
||||
}
|
||||
if (value >= 1_024) {
|
||||
return `${(value / 1_024).toFixed(2)} KB`;
|
||||
}
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
if (lowerUnit === "count" || lowerUnit === "samples") {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`;
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
return `${value} ${unit}`;
|
||||
}
|
||||
}
|
||||
184
App/FeatureSet/Docs/Content/telemetry/profiles.md
Normal file
184
App/FeatureSet/Docs/Content/telemetry/profiles.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Send Continuous Profiling Data to OneUptime
|
||||
|
||||
## Overview
|
||||
|
||||
Continuous profiling is the fourth pillar of observability alongside logs, metrics, and traces. Profiles capture how your application spends CPU time, allocates memory, and uses system resources at the function level. OneUptime ingests profiling data via the OpenTelemetry Protocol (OTLP) and stores it alongside your other telemetry signals for unified analysis.
|
||||
|
||||
With profiling data in OneUptime, you can identify hot functions consuming CPU, detect memory leaks, find contention bottlenecks, and correlate performance issues with specific traces and spans.
|
||||
|
||||
## Supported Profile Types
|
||||
|
||||
OneUptime supports the following profile types:
|
||||
|
||||
| Profile Type | Description | Unit |
|
||||
| --- | --- | --- |
|
||||
| cpu | CPU time spent executing code | nanoseconds |
|
||||
| wall | Wall-clock time (includes waiting/sleeping) | nanoseconds |
|
||||
| alloc_objects | Number of heap allocations | count |
|
||||
| alloc_space | Bytes of heap memory allocated | bytes |
|
||||
| goroutine | Number of active goroutines (Go) | count |
|
||||
| contention | Time spent waiting on locks/mutexes | nanoseconds |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Step 1 - Create a Telemetry Ingestion Token
|
||||
|
||||
After you sign up to OneUptime and create a project, click on "More" in the Navigation bar and click on "Project Settings".
|
||||
|
||||
On the Telemetry Ingestion Key page, click on "Create Ingestion Key" to create a token.
|
||||
|
||||

|
||||
|
||||
Once you created a token, click on "View" to view the token.
|
||||
|
||||

|
||||
|
||||
### Step 2 - Configure Your Profiler
|
||||
|
||||
OneUptime accepts profiling data over both gRPC and HTTP using the OTLP profiles protocol.
|
||||
|
||||
| Protocol | Endpoint |
|
||||
| --- | --- |
|
||||
| gRPC | `your-oneuptime-host:4317` (OTLP standard gRPC port) |
|
||||
| HTTP | `https://your-oneuptime-host/otlp/v1/profiles` |
|
||||
|
||||
**Environment Variables**
|
||||
|
||||
Set the following environment variables to point your profiler at OneUptime:
|
||||
|
||||
```bash
|
||||
export OTEL_EXPORTER_OTLP_HEADERS=x-oneuptime-token=YOUR_ONEUPTIME_SERVICE_TOKEN
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://oneuptime.com/otlp
|
||||
export OTEL_SERVICE_NAME=my-service
|
||||
```
|
||||
|
||||
**Self Hosted OneUptime**
|
||||
|
||||
If you are self-hosting OneUptime, replace the endpoint with your own host (e.g., `http(s)://YOUR-ONEUPTIME-HOST/otlp`). For gRPC, connect directly to port 4317 on your OneUptime host.
|
||||
|
||||
## Instrumentation Guide
|
||||
|
||||
### Using Grafana Alloy (eBPF-based profiling)
|
||||
|
||||
Grafana Alloy (formerly Grafana Agent) can collect CPU profiles from all processes on a Linux host using eBPF, with zero code changes required. Configure it to export via OTLP to OneUptime.
|
||||
|
||||
Example Alloy configuration:
|
||||
|
||||
```hcl
|
||||
pyroscope.ebpf "default" {
|
||||
forward_to = [pyroscope.write.oneuptime.receiver]
|
||||
targets = discovery.process.all.targets
|
||||
}
|
||||
|
||||
pyroscope.write "oneuptime" {
|
||||
endpoint {
|
||||
url = "https://oneuptime.com/otlp/v1/profiles"
|
||||
headers = {
|
||||
"x-oneuptime-token" = "YOUR_ONEUPTIME_SERVICE_TOKEN",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using async-profiler (Java)
|
||||
|
||||
For Java applications, use [async-profiler](https://github.com/async-profiler/async-profiler) with the OpenTelemetry Java agent to send profiling data via OTLP.
|
||||
|
||||
```bash
|
||||
# Start your Java application with the OpenTelemetry Java agent
|
||||
java -javaagent:opentelemetry-javaagent.jar \
|
||||
-Dotel.exporter.otlp.endpoint=https://oneuptime.com/otlp \
|
||||
-Dotel.exporter.otlp.headers=x-oneuptime-token=YOUR_ONEUPTIME_SERVICE_TOKEN \
|
||||
-Dotel.service.name=my-java-service \
|
||||
-jar my-app.jar
|
||||
```
|
||||
|
||||
### Using Go pprof with OTLP Export
|
||||
|
||||
For Go applications, you can use the standard `net/http/pprof` package alongside an OTLP exporter. Configure continuous profiling by periodically collecting pprof data and forwarding it to OneUptime.
|
||||
|
||||
```go
|
||||
import (
|
||||
"runtime/pprof"
|
||||
"bytes"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Collect a 30-second CPU profile and export periodically
|
||||
func collectProfile() {
|
||||
var buf bytes.Buffer
|
||||
pprof.StartCPUProfile(&buf)
|
||||
time.Sleep(30 * time.Second)
|
||||
pprof.StopCPUProfile()
|
||||
// Convert pprof output to OTLP format and send to OneUptime
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, use the OpenTelemetry Collector with a profiling receiver that scrapes your Go application's `/debug/pprof` endpoint and exports via OTLP.
|
||||
|
||||
### Using py-spy (Python)
|
||||
|
||||
For Python applications, [py-spy](https://github.com/benfred/py-spy) can capture CPU profiles without code changes. Use the OpenTelemetry Collector to receive and forward profile data.
|
||||
|
||||
```bash
|
||||
# Capture profiles and send to a local OTLP collector
|
||||
py-spy record --format speedscope --pid $PID -o profile.json
|
||||
```
|
||||
|
||||
For continuous profiling, run py-spy alongside your application and configure the OpenTelemetry Collector to ingest and forward the profiles to OneUptime.
|
||||
|
||||
## Using the OpenTelemetry Collector
|
||||
|
||||
You can use the OpenTelemetry Collector as a proxy to receive profiles from your applications and forward them to OneUptime.
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
exporters:
|
||||
otlphttp:
|
||||
endpoint: "https://oneuptime.com/otlp"
|
||||
encoding: json
|
||||
headers:
|
||||
"Content-Type": "application/json"
|
||||
"x-oneuptime-token": "YOUR_ONEUPTIME_SERVICE_TOKEN"
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
profiles:
|
||||
receivers: [otlp]
|
||||
exporters: [otlphttp]
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Flamegraph Visualization
|
||||
|
||||
OneUptime renders profile data as interactive flamegraphs. Each bar represents a function in the call stack, and its width is proportional to the time or resources consumed. You can click on any function to zoom in and see its callers and callees.
|
||||
|
||||
### Function List
|
||||
|
||||
View a sortable table of all functions captured in a profile, ranked by self time, total time, or allocation count. This helps you quickly identify the most expensive functions in your application.
|
||||
|
||||
### Trace Correlation
|
||||
|
||||
Profiles in OneUptime can be correlated with distributed traces. When a profile includes trace and span IDs (via the OTLP link table), you can navigate directly from a slow trace span to the corresponding CPU or memory profile to understand exactly what code was executing.
|
||||
|
||||
### Filtering by Profile Type
|
||||
|
||||
Filter profiles by type (cpu, wall, alloc_objects, alloc_space, goroutine, contention) to focus on the specific resource dimension you are investigating.
|
||||
|
||||
## Data Retention
|
||||
|
||||
Profile data retention is configured per telemetry service in your OneUptime project settings. The default retention period is 15 days. Data is automatically deleted after the retention period expires.
|
||||
|
||||
To change the retention period for a service, navigate to **Telemetry > Services > [Your Service] > Settings** and update the data retention value.
|
||||
|
||||
## Need Help?
|
||||
|
||||
Please contact support@oneuptime.com if you need any help setting up profiling with OneUptime.
|
||||
@@ -23,6 +23,12 @@ import LogAggregationService, {
|
||||
AnalyticsTopItem,
|
||||
AnalyticsTableRow,
|
||||
} from "../Services/LogAggregationService";
|
||||
import ProfileAggregationService, {
|
||||
FlamegraphRequest,
|
||||
FunctionListRequest,
|
||||
FunctionListItem,
|
||||
ProfileFlamegraphNode,
|
||||
} from "../Services/ProfileAggregationService";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import OneUptimeDate from "../../Types/Date";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
@@ -710,4 +716,164 @@ function computeDefaultBucketSize(startTime: Date, endTime: Date): number {
|
||||
return 1440;
|
||||
}
|
||||
|
||||
// --- Profile Get Attributes Endpoint ---
|
||||
|
||||
router.post(
|
||||
"/telemetry/profiles/get-attributes",
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
return getAttributes(req, res, next, TelemetryType.Profile);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Profile Flamegraph Endpoint ---
|
||||
|
||||
router.post(
|
||||
"/telemetry/profiles/flamegraph",
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const databaseProps: DatabaseCommonInteractionProps =
|
||||
await CommonAPI.getDatabaseCommonInteractionProps(req);
|
||||
|
||||
if (!databaseProps?.tenantId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid Project ID"),
|
||||
);
|
||||
}
|
||||
|
||||
const body: JSONObject = req.body as JSONObject;
|
||||
|
||||
const profileId: string | undefined = body["profileId"]
|
||||
? (body["profileId"] as string)
|
||||
: undefined;
|
||||
|
||||
const startTime: Date | undefined = body["startTime"]
|
||||
? OneUptimeDate.fromString(body["startTime"] as string)
|
||||
: undefined;
|
||||
|
||||
const endTime: Date | undefined = body["endTime"]
|
||||
? OneUptimeDate.fromString(body["endTime"] as string)
|
||||
: undefined;
|
||||
|
||||
const serviceIds: Array<ObjectID> | undefined = body["serviceIds"]
|
||||
? (body["serviceIds"] as Array<string>).map((id: string) => {
|
||||
return new ObjectID(id);
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const profileType: string | undefined = body["profileType"]
|
||||
? (body["profileType"] as string)
|
||||
: undefined;
|
||||
|
||||
if (!profileId && !startTime) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
"Either profileId or startTime must be provided",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const request: FlamegraphRequest = {
|
||||
projectId: databaseProps.tenantId,
|
||||
profileId,
|
||||
startTime,
|
||||
endTime,
|
||||
serviceIds,
|
||||
profileType,
|
||||
};
|
||||
|
||||
const flamegraph: ProfileFlamegraphNode =
|
||||
await ProfileAggregationService.getFlamegraph(request);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
flamegraph: flamegraph as unknown as JSONObject,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- Profile Function List Endpoint ---
|
||||
|
||||
router.post(
|
||||
"/telemetry/profiles/function-list",
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const databaseProps: DatabaseCommonInteractionProps =
|
||||
await CommonAPI.getDatabaseCommonInteractionProps(req);
|
||||
|
||||
if (!databaseProps?.tenantId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid Project ID"),
|
||||
);
|
||||
}
|
||||
|
||||
const body: JSONObject = req.body as JSONObject;
|
||||
|
||||
const startTime: Date = body["startTime"]
|
||||
? OneUptimeDate.fromString(body["startTime"] as string)
|
||||
: OneUptimeDate.addRemoveHours(OneUptimeDate.getCurrentDate(), -1);
|
||||
|
||||
const endTime: Date = body["endTime"]
|
||||
? OneUptimeDate.fromString(body["endTime"] as string)
|
||||
: OneUptimeDate.getCurrentDate();
|
||||
|
||||
const serviceIds: Array<ObjectID> | undefined = body["serviceIds"]
|
||||
? (body["serviceIds"] as Array<string>).map((id: string) => {
|
||||
return new ObjectID(id);
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const profileType: string | undefined = body["profileType"]
|
||||
? (body["profileType"] as string)
|
||||
: undefined;
|
||||
|
||||
const limit: number | undefined = body["limit"]
|
||||
? (body["limit"] as number)
|
||||
: undefined;
|
||||
|
||||
const sortBy: "selfValue" | "totalValue" | "sampleCount" | undefined =
|
||||
body["sortBy"]
|
||||
? (body["sortBy"] as "selfValue" | "totalValue" | "sampleCount")
|
||||
: undefined;
|
||||
|
||||
const request: FunctionListRequest = {
|
||||
projectId: databaseProps.tenantId,
|
||||
startTime,
|
||||
endTime,
|
||||
serviceIds,
|
||||
profileType,
|
||||
limit,
|
||||
sortBy,
|
||||
};
|
||||
|
||||
const functions: Array<FunctionListItem> =
|
||||
await ProfileAggregationService.getFunctionList(request);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
functions: functions as unknown as JSONObject,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -427,6 +427,14 @@ export const AverageMetricRowSizeInBytes: number = parsePositiveNumberFromEnv(
|
||||
export const AverageExceptionRowSizeInBytes: number =
|
||||
parsePositiveNumberFromEnv("AVERAGE_EXCEPTION_ROW_SIZE_IN_BYTES", 1024);
|
||||
|
||||
export const AverageProfileRowSizeInBytes: number = parsePositiveNumberFromEnv(
|
||||
"AVERAGE_PROFILE_ROW_SIZE_IN_BYTES",
|
||||
1024,
|
||||
);
|
||||
|
||||
export const AverageProfileSampleRowSizeInBytes: number =
|
||||
parsePositiveNumberFromEnv("AVERAGE_PROFILE_SAMPLE_ROW_SIZE_IN_BYTES", 512);
|
||||
|
||||
export const SlackAppClientId: string | null =
|
||||
process.env["SLACK_APP_CLIENT_ID"] || null;
|
||||
export const SlackAppClientSecret: string | null =
|
||||
|
||||
@@ -1350,19 +1350,22 @@ ${incident.remediationNotes || "No remediation notes provided."}
|
||||
] as Date;
|
||||
|
||||
// find the resolved state timeline to calculate time from resolution to postmortem
|
||||
const resolvedStateId: ObjectID =
|
||||
await IncidentStateTimelineService.getResolvedStateIdForProject(
|
||||
projectId,
|
||||
);
|
||||
|
||||
const resolvedTimeline: IncidentStateTimeline | null =
|
||||
await IncidentStateTimelineService.findOneBy({
|
||||
query: {
|
||||
incidentId: incidentId,
|
||||
incidentStateId: resolvedStateId,
|
||||
},
|
||||
select: {
|
||||
startsAt: true,
|
||||
incidentState: {
|
||||
isResolvedState: true,
|
||||
},
|
||||
},
|
||||
sort: {
|
||||
startsAt: SortOrder.Ascending,
|
||||
startsAt: SortOrder.Descending,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
@@ -1370,7 +1373,7 @@ ${incident.remediationNotes || "No remediation notes provided."}
|
||||
});
|
||||
|
||||
// only emit if the incident has been resolved
|
||||
if (resolvedTimeline?.incidentState?.isResolvedState && resolvedTimeline.startsAt) {
|
||||
if (resolvedTimeline && resolvedTimeline.startsAt) {
|
||||
const postmortemMetric: Metric = new Metric();
|
||||
postmortemMetric.projectId = projectId;
|
||||
postmortemMetric.serviceId = incidentId;
|
||||
|
||||
417
Common/Server/Services/ProfileAggregationService.ts
Normal file
417
Common/Server/Services/ProfileAggregationService.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { SQL, Statement } from "../Utils/AnalyticsDatabase/Statement";
|
||||
import ProfileSampleDatabaseService from "./ProfileSampleService";
|
||||
import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Includes from "../../Types/BaseDatabase/Includes";
|
||||
import AnalyticsTableName from "../../Types/AnalyticsDatabase/AnalyticsTableName";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import { DbJSONResponse, Results } from "./AnalyticsDatabaseService";
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
export interface ProfileFlamegraphNode {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
children: ProfileFlamegraphNode[];
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
export interface FlamegraphRequest {
|
||||
projectId: ObjectID;
|
||||
profileId?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
serviceIds?: Array<ObjectID>;
|
||||
profileType?: string;
|
||||
}
|
||||
|
||||
export interface FunctionListItem {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
export interface FunctionListRequest {
|
||||
projectId: ObjectID;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
serviceIds?: Array<ObjectID>;
|
||||
profileType?: string;
|
||||
limit?: number;
|
||||
sortBy?: "selfValue" | "totalValue" | "sampleCount";
|
||||
}
|
||||
|
||||
interface ParsedFrame {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
// --- Service ---
|
||||
|
||||
export class ProfileAggregationService {
|
||||
private static readonly TABLE_NAME: string =
|
||||
AnalyticsTableName.ProfileSample;
|
||||
private static readonly DEFAULT_FUNCTION_LIST_LIMIT: number = 50;
|
||||
private static readonly MAX_SAMPLE_FETCH: number = 50000;
|
||||
|
||||
/**
|
||||
* Build a flamegraph tree from ProfileSample records.
|
||||
*
|
||||
* Each sample has a `stacktrace` array where each element follows the
|
||||
* format "functionName@fileName:lineNumber". The array is ordered
|
||||
* bottom-up (index 0 = root, last index = leaf).
|
||||
*
|
||||
* We aggregate samples that share common stack prefixes into a tree of
|
||||
* ProfileFlamegraphNode objects.
|
||||
*/
|
||||
@CaptureSpan()
|
||||
public static async getFlamegraph(
|
||||
request: FlamegraphRequest,
|
||||
): Promise<ProfileFlamegraphNode> {
|
||||
const statement: Statement =
|
||||
ProfileAggregationService.buildFlamegraphQuery(request);
|
||||
|
||||
const dbResult: Results =
|
||||
await ProfileSampleDatabaseService.executeQuery(statement);
|
||||
const response: DbJSONResponse = await dbResult.json<{
|
||||
data?: Array<JSONObject>;
|
||||
}>();
|
||||
|
||||
const rows: Array<JSONObject> = response.data || [];
|
||||
|
||||
// Build the tree from samples
|
||||
const root: ProfileFlamegraphNode = {
|
||||
functionName: "(root)",
|
||||
fileName: "",
|
||||
lineNumber: 0,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: [],
|
||||
frameType: "",
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const stacktrace: Array<string> = (row["stacktrace"] as Array<string>) || [];
|
||||
const frameTypes: Array<string> = (row["frameTypes"] as Array<string>) || [];
|
||||
const value: number = Number(row["value"] || 0);
|
||||
|
||||
if (stacktrace.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Walk down the tree, creating nodes as needed
|
||||
let currentNode: ProfileFlamegraphNode = root;
|
||||
currentNode.totalValue += value;
|
||||
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: ParsedFrame = ProfileAggregationService.parseFrame(
|
||||
stacktrace[i]!,
|
||||
);
|
||||
const frameType: string = frameTypes[i] || "";
|
||||
|
||||
// Find or create child
|
||||
let childNode: ProfileFlamegraphNode | undefined =
|
||||
currentNode.children.find(
|
||||
(child: ProfileFlamegraphNode): boolean => {
|
||||
return (
|
||||
child.functionName === frame.functionName &&
|
||||
child.fileName === frame.fileName &&
|
||||
child.lineNumber === frame.lineNumber
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!childNode) {
|
||||
childNode = {
|
||||
functionName: frame.functionName,
|
||||
fileName: frame.fileName,
|
||||
lineNumber: frame.lineNumber,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: [],
|
||||
frameType: frameType,
|
||||
};
|
||||
currentNode.children.push(childNode);
|
||||
}
|
||||
|
||||
childNode.totalValue += value;
|
||||
|
||||
// If this is the leaf frame, add to selfValue
|
||||
if (i === stacktrace.length - 1) {
|
||||
childNode.selfValue += value;
|
||||
}
|
||||
|
||||
currentNode = childNode;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the top functions aggregated across samples, sorted by the
|
||||
* requested metric (selfValue, totalValue, or sampleCount).
|
||||
*/
|
||||
@CaptureSpan()
|
||||
public static async getFunctionList(
|
||||
request: FunctionListRequest,
|
||||
): Promise<Array<FunctionListItem>> {
|
||||
const statement: Statement =
|
||||
ProfileAggregationService.buildFunctionListQuery(request);
|
||||
|
||||
const dbResult: Results =
|
||||
await ProfileSampleDatabaseService.executeQuery(statement);
|
||||
const response: DbJSONResponse = await dbResult.json<{
|
||||
data?: Array<JSONObject>;
|
||||
}>();
|
||||
|
||||
const rows: Array<JSONObject> = response.data || [];
|
||||
|
||||
// Aggregate per-function stats in-memory from the raw samples
|
||||
const functionMap: Map<
|
||||
string,
|
||||
{
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
frameType: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
const stacktrace: Array<string> = (row["stacktrace"] as Array<string>) || [];
|
||||
const frameTypes: Array<string> = (row["frameTypes"] as Array<string>) || [];
|
||||
const value: number = Number(row["value"] || 0);
|
||||
|
||||
if (stacktrace.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seenInThisSample: Set<string> = new Set();
|
||||
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: ParsedFrame = ProfileAggregationService.parseFrame(
|
||||
stacktrace[i]!,
|
||||
);
|
||||
const frameType: string = frameTypes[i] || "";
|
||||
const key: string = `${frame.functionName}@${frame.fileName}:${frame.lineNumber}`;
|
||||
const isLeaf: boolean = i === stacktrace.length - 1;
|
||||
|
||||
let entry = functionMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
functionName: frame.functionName,
|
||||
fileName: frame.fileName,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
sampleCount: 0,
|
||||
frameType: frameType,
|
||||
};
|
||||
functionMap.set(key, entry);
|
||||
}
|
||||
|
||||
// totalValue: count the value once per unique function per sample
|
||||
if (!seenInThisSample.has(key)) {
|
||||
entry.totalValue += value;
|
||||
entry.sampleCount += 1;
|
||||
seenInThisSample.add(key);
|
||||
}
|
||||
|
||||
// selfValue: only the leaf frame
|
||||
if (isLeaf) {
|
||||
entry.selfValue += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sortBy: string = request.sortBy || "selfValue";
|
||||
const items: Array<FunctionListItem> = Array.from(functionMap.values());
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (sortBy === "totalValue") {
|
||||
return b.totalValue - a.totalValue;
|
||||
}
|
||||
|
||||
if (sortBy === "sampleCount") {
|
||||
return b.sampleCount - a.sampleCount;
|
||||
}
|
||||
|
||||
return b.selfValue - a.selfValue;
|
||||
});
|
||||
|
||||
const limit: number =
|
||||
request.limit ?? ProfileAggregationService.DEFAULT_FUNCTION_LIST_LIMIT;
|
||||
|
||||
return items.slice(0, limit);
|
||||
}
|
||||
|
||||
// --- Query builders ---
|
||||
|
||||
private static buildFlamegraphQuery(request: FlamegraphRequest): Statement {
|
||||
const statement: Statement = SQL`
|
||||
SELECT
|
||||
stacktrace,
|
||||
frameTypes,
|
||||
value
|
||||
FROM ${ProfileAggregationService.TABLE_NAME}
|
||||
WHERE projectId = ${{
|
||||
type: TableColumnType.ObjectID,
|
||||
value: request.projectId,
|
||||
}}
|
||||
`;
|
||||
|
||||
if (request.profileId) {
|
||||
statement.append(
|
||||
SQL` AND profileId = ${{
|
||||
type: TableColumnType.Text,
|
||||
value: request.profileId,
|
||||
}}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.startTime) {
|
||||
statement.append(
|
||||
SQL` AND time >= ${{
|
||||
type: TableColumnType.Date,
|
||||
value: request.startTime,
|
||||
}}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.endTime) {
|
||||
statement.append(
|
||||
SQL` AND time <= ${{
|
||||
type: TableColumnType.Date,
|
||||
value: request.endTime,
|
||||
}}`,
|
||||
);
|
||||
}
|
||||
|
||||
ProfileAggregationService.appendCommonFilters(statement, request);
|
||||
|
||||
statement.append(
|
||||
SQL` LIMIT ${{
|
||||
type: TableColumnType.Number,
|
||||
value: ProfileAggregationService.MAX_SAMPLE_FETCH,
|
||||
}}`,
|
||||
);
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
private static buildFunctionListQuery(
|
||||
request: FunctionListRequest,
|
||||
): Statement {
|
||||
const statement: Statement = SQL`
|
||||
SELECT
|
||||
stacktrace,
|
||||
frameTypes,
|
||||
value
|
||||
FROM ${ProfileAggregationService.TABLE_NAME}
|
||||
WHERE projectId = ${{
|
||||
type: TableColumnType.ObjectID,
|
||||
value: request.projectId,
|
||||
}}
|
||||
AND time >= ${{
|
||||
type: TableColumnType.Date,
|
||||
value: request.startTime,
|
||||
}}
|
||||
AND time <= ${{
|
||||
type: TableColumnType.Date,
|
||||
value: request.endTime,
|
||||
}}
|
||||
`;
|
||||
|
||||
ProfileAggregationService.appendCommonFilters(statement, request);
|
||||
|
||||
statement.append(
|
||||
SQL` LIMIT ${{
|
||||
type: TableColumnType.Number,
|
||||
value: ProfileAggregationService.MAX_SAMPLE_FETCH,
|
||||
}}`,
|
||||
);
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
private static appendCommonFilters(
|
||||
statement: Statement,
|
||||
request: Pick<FlamegraphRequest, "serviceIds" | "profileType">,
|
||||
): void {
|
||||
if (request.serviceIds && request.serviceIds.length > 0) {
|
||||
statement.append(
|
||||
SQL` AND serviceId IN (${{
|
||||
type: TableColumnType.ObjectID,
|
||||
value: new Includes(
|
||||
request.serviceIds.map((id: ObjectID) => {
|
||||
return id.toString();
|
||||
}),
|
||||
),
|
||||
}})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.profileType) {
|
||||
statement.append(
|
||||
SQL` AND profileType = ${{
|
||||
type: TableColumnType.Text,
|
||||
value: request.profileType,
|
||||
}}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a frame string in the format "functionName@fileName:lineNumber".
|
||||
* Falls back gracefully if the format is unexpected.
|
||||
*/
|
||||
private static parseFrame(frame: string): ParsedFrame {
|
||||
// Expected format: "functionName@fileName:lineNumber"
|
||||
const atIndex: number = frame.indexOf("@");
|
||||
|
||||
if (atIndex === -1) {
|
||||
return {
|
||||
functionName: frame,
|
||||
fileName: "",
|
||||
lineNumber: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const functionName: string = frame.substring(0, atIndex);
|
||||
const rest: string = frame.substring(atIndex + 1);
|
||||
|
||||
const lastColonIndex: number = rest.lastIndexOf(":");
|
||||
|
||||
if (lastColonIndex === -1) {
|
||||
return {
|
||||
functionName,
|
||||
fileName: rest,
|
||||
lineNumber: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const fileName: string = rest.substring(0, lastColonIndex);
|
||||
const lineNumberStr: string = rest.substring(lastColonIndex + 1);
|
||||
const lineNumber: number = parseInt(lineNumberStr, 10) || 0;
|
||||
|
||||
return {
|
||||
functionName,
|
||||
fileName,
|
||||
lineNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileAggregationService;
|
||||
@@ -16,6 +16,8 @@ import SpanService from "./SpanService";
|
||||
import LogService from "./LogService";
|
||||
import MetricService from "./MetricService";
|
||||
import ExceptionInstanceService from "./ExceptionInstanceService";
|
||||
import ProfileService from "./ProfileService";
|
||||
import ProfileSampleService from "./ProfileSampleService";
|
||||
import AnalyticsQueryHelper from "../Types/AnalyticsDatabase/QueryHelper";
|
||||
import DiskSize from "../../Types/DiskSize";
|
||||
import logger from "../Utils/Logger";
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
AverageLogRowSizeInBytes,
|
||||
AverageMetricRowSizeInBytes,
|
||||
AverageExceptionRowSizeInBytes,
|
||||
AverageProfileRowSizeInBytes,
|
||||
AverageProfileSampleRowSizeInBytes,
|
||||
IsBillingEnabled,
|
||||
} from "../EnvironmentConfig";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
@@ -81,7 +85,14 @@ export class Service extends DatabaseService<Model> {
|
||||
const averageExceptionRowSizeInBytes: number =
|
||||
this.getAverageExceptionRowSize();
|
||||
|
||||
if (data.productType !== ProductType.Traces && averageRowSizeInBytes <= 0) {
|
||||
const averageProfileSampleRowSizeInBytes: number =
|
||||
this.getAverageProfileSampleRowSize();
|
||||
|
||||
if (
|
||||
data.productType !== ProductType.Traces &&
|
||||
data.productType !== ProductType.Profiles &&
|
||||
averageRowSizeInBytes <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,6 +104,14 @@ export class Service extends DatabaseService<Model> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data.productType === ProductType.Profiles &&
|
||||
averageRowSizeInBytes <= 0 &&
|
||||
averageProfileSampleRowSizeInBytes <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageDayString: string = OneUptimeDate.getDateString(usageDate);
|
||||
const startOfDay: Date = OneUptimeDate.getStartOfDay(usageDate);
|
||||
const endOfDay: Date = OneUptimeDate.getEndOfDay(usageDate);
|
||||
@@ -223,6 +242,45 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
|
||||
estimatedBytes = totalRowCount * averageRowSizeInBytes;
|
||||
} else if (data.productType === ProductType.Profiles) {
|
||||
const profileCount: PositiveNumber = await ProfileService.countBy({
|
||||
query: {
|
||||
projectId: data.projectId,
|
||||
serviceId: service.id,
|
||||
startTime: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_INFINITY,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const profileSampleCount: PositiveNumber =
|
||||
await ProfileSampleService.countBy({
|
||||
query: {
|
||||
projectId: data.projectId,
|
||||
serviceId: service.id,
|
||||
time: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_INFINITY,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const totalProfileCount: number = profileCount.toNumber();
|
||||
const totalProfileSampleCount: number =
|
||||
profileSampleCount.toNumber();
|
||||
|
||||
if (totalProfileCount <= 0 && totalProfileSampleCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
estimatedBytes =
|
||||
totalProfileCount * averageRowSizeInBytes +
|
||||
totalProfileSampleCount * averageProfileSampleRowSizeInBytes;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -268,7 +326,8 @@ export class Service extends DatabaseService<Model> {
|
||||
if (
|
||||
data.productType !== ProductType.Traces &&
|
||||
data.productType !== ProductType.Metrics &&
|
||||
data.productType !== ProductType.Logs
|
||||
data.productType !== ProductType.Logs &&
|
||||
data.productType !== ProductType.Profiles
|
||||
) {
|
||||
throw new BadDataException(
|
||||
"This product type is not a telemetry product type.",
|
||||
@@ -370,7 +429,8 @@ export class Service extends DatabaseService<Model> {
|
||||
if (
|
||||
productType !== ProductType.Traces &&
|
||||
productType !== ProductType.Logs &&
|
||||
productType !== ProductType.Metrics
|
||||
productType !== ProductType.Metrics &&
|
||||
productType !== ProductType.Profiles
|
||||
) {
|
||||
return fallbackSize;
|
||||
}
|
||||
@@ -380,6 +440,7 @@ export class Service extends DatabaseService<Model> {
|
||||
[ProductType.Traces]: AverageSpanRowSizeInBytes,
|
||||
[ProductType.Logs]: AverageLogRowSizeInBytes,
|
||||
[ProductType.Metrics]: AverageMetricRowSizeInBytes,
|
||||
[ProductType.Profiles]: AverageProfileRowSizeInBytes,
|
||||
}[productType] ?? fallbackSize;
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
@@ -402,6 +463,20 @@ export class Service extends DatabaseService<Model> {
|
||||
|
||||
return AverageExceptionRowSizeInBytes;
|
||||
}
|
||||
|
||||
private getAverageProfileSampleRowSize(): number {
|
||||
const fallbackSize: number = 512;
|
||||
|
||||
if (!Number.isFinite(AverageProfileSampleRowSizeInBytes)) {
|
||||
return fallbackSize;
|
||||
}
|
||||
|
||||
if (AverageProfileSampleRowSizeInBytes <= 0) {
|
||||
return fallbackSize;
|
||||
}
|
||||
|
||||
return AverageProfileSampleRowSizeInBytes;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
|
||||
@@ -26,11 +26,18 @@ export const TracesDataIngestMetredPlan: TelemetryMeteredPlanType =
|
||||
unitCostInUSD: 0.1 / 15, // 0.10 per 15 days per GB
|
||||
});
|
||||
|
||||
export const ProfilesDataIngestMeteredPlan: TelemetryMeteredPlanType =
|
||||
new TelemetryMeteredPlanType({
|
||||
productType: ProductType.Profiles,
|
||||
unitCostInUSD: 0.1 / 15, // 0.10 per 15 days per GB
|
||||
});
|
||||
|
||||
const AllMeteredPlans: Array<ServerMeteredPlan> = [
|
||||
ActiveMonitoringMeteredPlan,
|
||||
LogDataIngestMeteredPlan,
|
||||
MetricsDataIngestMeteredPlan,
|
||||
TracesDataIngestMetredPlan,
|
||||
ProfilesDataIngestMeteredPlan,
|
||||
];
|
||||
|
||||
export class MeteredPlanUtil {
|
||||
@@ -44,6 +51,8 @@ export class MeteredPlanUtil {
|
||||
return MetricsDataIngestMeteredPlan;
|
||||
} else if (productType === ProductType.Traces) {
|
||||
return TracesDataIngestMetredPlan;
|
||||
} else if (productType === ProductType.Profiles) {
|
||||
return ProfilesDataIngestMeteredPlan;
|
||||
} else if (productType === ProductType.ActiveMonitoring) {
|
||||
return ActiveMonitoringMeteredPlan;
|
||||
}
|
||||
|
||||
98
Common/Types/Monitor/MonitorStepProfileMonitor.ts
Normal file
98
Common/Types/Monitor/MonitorStepProfileMonitor.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import Profile from "../../Models/AnalyticsModels/Profile";
|
||||
import InBetween from "../BaseDatabase/InBetween";
|
||||
import Includes from "../BaseDatabase/Includes";
|
||||
import Query from "../BaseDatabase/Query";
|
||||
import Search from "../BaseDatabase/Search";
|
||||
import OneUptimeDate from "../Date";
|
||||
import Dictionary from "../Dictionary";
|
||||
import { JSONObject } from "../JSON";
|
||||
import ObjectID from "../ObjectID";
|
||||
|
||||
export default interface MonitorStepProfileMonitor {
|
||||
attributes: Dictionary<string | number | boolean>;
|
||||
profileTypes: Array<string>;
|
||||
telemetryServiceIds: Array<ObjectID>;
|
||||
lastXSecondsOfProfiles: number;
|
||||
profileType: string;
|
||||
}
|
||||
|
||||
export class MonitorStepProfileMonitorUtil {
|
||||
public static toQuery(
|
||||
monitorStepProfileMonitor: MonitorStepProfileMonitor,
|
||||
): Query<Profile> {
|
||||
const query: Query<Profile> = {};
|
||||
|
||||
if (
|
||||
monitorStepProfileMonitor.telemetryServiceIds &&
|
||||
monitorStepProfileMonitor.telemetryServiceIds.length > 0
|
||||
) {
|
||||
query.serviceId = new Includes(
|
||||
monitorStepProfileMonitor.telemetryServiceIds,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
monitorStepProfileMonitor.attributes &&
|
||||
Object.keys(monitorStepProfileMonitor.attributes).length > 0
|
||||
) {
|
||||
query.attributes = monitorStepProfileMonitor.attributes;
|
||||
}
|
||||
|
||||
if (
|
||||
monitorStepProfileMonitor.profileTypes &&
|
||||
monitorStepProfileMonitor.profileTypes.length > 0
|
||||
) {
|
||||
query.profileType = new Includes(
|
||||
monitorStepProfileMonitor.profileTypes,
|
||||
);
|
||||
}
|
||||
|
||||
if (monitorStepProfileMonitor.profileType) {
|
||||
query.profileType = new Search(monitorStepProfileMonitor.profileType);
|
||||
}
|
||||
|
||||
if (monitorStepProfileMonitor.lastXSecondsOfProfiles) {
|
||||
const endDate: Date = OneUptimeDate.getCurrentDate();
|
||||
const startDate: Date = OneUptimeDate.addRemoveSeconds(
|
||||
endDate,
|
||||
monitorStepProfileMonitor.lastXSecondsOfProfiles * -1,
|
||||
);
|
||||
query.startTime = new InBetween(startDate, endDate);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public static getDefault(): MonitorStepProfileMonitor {
|
||||
return {
|
||||
attributes: {},
|
||||
profileType: "",
|
||||
profileTypes: [],
|
||||
telemetryServiceIds: [],
|
||||
lastXSecondsOfProfiles: 60,
|
||||
};
|
||||
}
|
||||
|
||||
public static fromJSON(json: JSONObject): MonitorStepProfileMonitor {
|
||||
return {
|
||||
attributes:
|
||||
(json["attributes"] as Dictionary<string | number | boolean>) || {},
|
||||
profileType: json["profileType"] as string,
|
||||
profileTypes: json["profileTypes"] as Array<string>,
|
||||
telemetryServiceIds: ObjectID.fromJSONArray(
|
||||
json["telemetryServiceIds"] as Array<JSONObject>,
|
||||
),
|
||||
lastXSecondsOfProfiles: json["lastXSecondsOfProfiles"] as number,
|
||||
};
|
||||
}
|
||||
|
||||
public static toJSON(monitor: MonitorStepProfileMonitor): JSONObject {
|
||||
return {
|
||||
attributes: monitor.attributes,
|
||||
profileType: monitor.profileType,
|
||||
profileTypes: monitor.profileTypes,
|
||||
telemetryServiceIds: ObjectID.toJSONArray(monitor.telemetryServiceIds),
|
||||
lastXSecondsOfProfiles: monitor.lastXSecondsOfProfiles,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,12 @@ Usage:
|
||||
- name: AVERAGE_EXCEPTION_ROW_SIZE_IN_BYTES
|
||||
value: {{ $.Values.billing.telemetry.averageExceptionRowSizeInBytes | quote }}
|
||||
|
||||
- name: AVERAGE_PROFILE_ROW_SIZE_IN_BYTES
|
||||
value: {{ $.Values.billing.telemetry.averageProfileRowSizeInBytes | quote }}
|
||||
|
||||
- name: AVERAGE_PROFILE_SAMPLE_ROW_SIZE_IN_BYTES
|
||||
value: {{ $.Values.billing.telemetry.averageProfileSampleRowSizeInBytes | quote }}
|
||||
|
||||
{{- include "oneuptime.env.oneuptimeSecret" . }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
@@ -672,6 +672,14 @@
|
||||
"averageExceptionRowSizeInBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"averageProfileRowSizeInBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"averageProfileSampleRowSizeInBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -275,6 +275,8 @@ billing:
|
||||
averageLogRowSizeInBytes: 1024
|
||||
averageMetricRowSizeInBytes: 1024
|
||||
averageExceptionRowSizeInBytes: 1024
|
||||
averageProfileRowSizeInBytes: 1024
|
||||
averageProfileSampleRowSizeInBytes: 512
|
||||
|
||||
subscriptionPlan:
|
||||
basic:
|
||||
|
||||
281
Telemetry/Docs/examples/otlp-profiles-payload.json
Normal file
281
Telemetry/Docs/examples/otlp-profiles-payload.json
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"resourceProfiles": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "my-go-service"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "host.name",
|
||||
"value": {
|
||||
"stringValue": "prod-server-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "process.runtime.name",
|
||||
"value": {
|
||||
"stringValue": "go"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "process.runtime.version",
|
||||
"value": {
|
||||
"stringValue": "go1.22.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "telemetry.sdk.name",
|
||||
"value": {
|
||||
"stringValue": "opentelemetry"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "telemetry.sdk.language",
|
||||
"value": {
|
||||
"stringValue": "go"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "telemetry.sdk.version",
|
||||
"value": {
|
||||
"stringValue": "1.28.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"scopeProfiles": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "otel-profiling-go",
|
||||
"version": "0.5.0"
|
||||
},
|
||||
"profiles": [
|
||||
{
|
||||
"profileId": "qg7PaWLjuqLhWlwvlHRU9A==",
|
||||
"startTimeUnixNano": "1700000000000000000",
|
||||
"endTimeUnixNano": "1700000030000000000",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "profiling.data.type",
|
||||
"value": {
|
||||
"stringValue": "cpu"
|
||||
}
|
||||
}
|
||||
],
|
||||
"originalPayloadFormat": "pprofext",
|
||||
"profile": {
|
||||
"stringTable": [
|
||||
"",
|
||||
"cpu",
|
||||
"nanoseconds",
|
||||
"samples",
|
||||
"count",
|
||||
"main.handleRequest",
|
||||
"/app/main.go",
|
||||
"net/http.(*conn).serve",
|
||||
"/usr/local/go/src/net/http/server.go",
|
||||
"runtime.goexit",
|
||||
"/usr/local/go/src/runtime/asm_amd64.s",
|
||||
"main.processData",
|
||||
"/app/processor.go",
|
||||
"encoding/json.Marshal",
|
||||
"/usr/local/go/src/encoding/json/encode.go",
|
||||
"runtime.mallocgc",
|
||||
"/usr/local/go/src/runtime/malloc.go",
|
||||
"main.queryDatabase",
|
||||
"/app/db.go",
|
||||
"database/sql.(*DB).QueryContext",
|
||||
"/usr/local/go/src/database/sql/sql.go"
|
||||
],
|
||||
"sampleType": [
|
||||
{
|
||||
"type": 1,
|
||||
"unit": 2
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"unit": 4
|
||||
}
|
||||
],
|
||||
"periodType": {
|
||||
"type": 1,
|
||||
"unit": 2
|
||||
},
|
||||
"period": 10000000,
|
||||
"functionTable": [
|
||||
{
|
||||
"name": 5,
|
||||
"filename": 6
|
||||
},
|
||||
{
|
||||
"name": 7,
|
||||
"filename": 8
|
||||
},
|
||||
{
|
||||
"name": 9,
|
||||
"filename": 10
|
||||
},
|
||||
{
|
||||
"name": 11,
|
||||
"filename": 12
|
||||
},
|
||||
{
|
||||
"name": 13,
|
||||
"filename": 14
|
||||
},
|
||||
{
|
||||
"name": 15,
|
||||
"filename": 16
|
||||
},
|
||||
{
|
||||
"name": 17,
|
||||
"filename": 18
|
||||
},
|
||||
{
|
||||
"name": 19,
|
||||
"filename": 20
|
||||
}
|
||||
],
|
||||
"locationTable": [
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 0,
|
||||
"line": 42
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 1,
|
||||
"line": 1960
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 2,
|
||||
"line": 1700
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 3,
|
||||
"line": 88
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 4,
|
||||
"line": 160
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 5,
|
||||
"line": 905
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 6,
|
||||
"line": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"line": [
|
||||
{
|
||||
"functionIndex": 7,
|
||||
"line": 1612
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stackTable": [
|
||||
{
|
||||
"locationIndices": [0, 1, 2]
|
||||
},
|
||||
{
|
||||
"locationIndices": [3, 4, 0, 1, 2]
|
||||
},
|
||||
{
|
||||
"locationIndices": [5, 3, 0, 1, 2]
|
||||
},
|
||||
{
|
||||
"locationIndices": [6, 7, 0, 1, 2]
|
||||
}
|
||||
],
|
||||
"linkTable": [
|
||||
{
|
||||
"traceId": "qg7PaWLjuqLhWlwvlHRU9A==",
|
||||
"spanId": "r+N4WZXXfP4="
|
||||
}
|
||||
],
|
||||
"attributeTable": [
|
||||
{
|
||||
"key": "profile.frame.type",
|
||||
"value": {
|
||||
"stringValue": "go"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "thread.name",
|
||||
"value": {
|
||||
"stringValue": "main"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sample": [
|
||||
{
|
||||
"stackIndex": 0,
|
||||
"value": [50000000, 5],
|
||||
"timestampsUnixNano": ["1700000005000000000"],
|
||||
"linkIndex": 0,
|
||||
"attributeIndices": [0, 1]
|
||||
},
|
||||
{
|
||||
"stackIndex": 1,
|
||||
"value": [120000000, 12],
|
||||
"timestampsUnixNano": ["1700000010000000000"],
|
||||
"linkIndex": 0,
|
||||
"attributeIndices": [0]
|
||||
},
|
||||
{
|
||||
"stackIndex": 2,
|
||||
"value": [30000000, 3],
|
||||
"timestampsUnixNano": ["1700000015000000000"],
|
||||
"linkIndex": 0,
|
||||
"attributeIndices": [0]
|
||||
},
|
||||
{
|
||||
"stackIndex": 3,
|
||||
"value": [80000000, 8],
|
||||
"timestampsUnixNano": ["1700000020000000000"],
|
||||
"linkIndex": 0,
|
||||
"attributeIndices": [0]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"schemaUrl": "https://opentelemetry.io/schemas/1.21.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user