Feat/jaeger (#243)

* added dummy jaeger json file

* added jaeger types file

* added dev jaeger endpoint

* added jaeger modal with trace visual / information

* refactored jaeger logic
fixed offsets on short duration spans

* refactored into smaller components & added basic scroll bar

* removed buttons, added scroll-x, fixed details height and scroll-y

* shrunk bar graph to fit view port

* slight adjustments

* removed ref and now calculate width based on window innerwidth

* @media for phone layouts

* -fixed details width and padding.
-removed scroll.tsx as not needed now
-using SpanKind to find parent for now

* -reduced truncate size for smaller screens

* -root span is now determined from parentSpanId not being found

* removed un-needed calls to /getRecentCalls as this was causing a race condition when pcap & jaeger fetching at same time
- removed console.log's

* wip: add tabs for recent callt tracing

* wip: add tabs for recent callt tracing

* wip: add tabs for recent callt tracing

* fix: review comments

* fix: review comments

---------

Co-authored-by: ajukes <ajukes@vibecoms.co.uk>
This commit is contained in:
Hoan Luu Huu
2023-05-06 07:12:46 +07:00
committed by GitHub
parent 414bca4fdc
commit bfbd66ef5c
14 changed files with 1849 additions and 31 deletions

View File

@@ -48,6 +48,7 @@ app.get(
remote_host: "3.55.24.34",
direction: 0 === i % 2 ? "inbound" : "outbound",
trunk: 0 === i % 2 ? "twilio" : "user",
trace_id: nanoid(),
};
data.push(call);
}
@@ -136,6 +137,17 @@ app.get(
}
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
(req: Request, res: Response) => {
const json = fs.readFileSync(
path.resolve(process.cwd(), "server", "sample-jaeger.json"),
{ encoding: "utf8" }
);
res.status(200).json(JSON.parse(json));
}
);
/** Alerts mock API responses for local dev */
app.get("/api/Accounts/:account_sid/Alerts", (req: Request, res: Response) => {
const data: Alert[] = [];

1159
server/sample-jaeger.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,7 @@ import type {
LcrCarrierSetEntry,
} from "./types";
import { StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
/** Wrap all requests to normalize response handling */
const fetchTransport = <Type>(
@@ -633,6 +634,14 @@ export const getPcap = (sid: string, sipCallId: string) => {
);
};
export const getJaegerTrace = (sid: string, traceId: string) => {
return getFetch<JaegerRoot>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/trace/${traceId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/trace/${traceId}`
);
};
export const getServiceProviderRecentCall = (
sid: string,
sipCallId: string

62
src/api/jaeger-types.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface JaegerRoot {
resourceSpans: JaegerResourceSpan[];
}
export interface JaegerResourceSpan {
resource: JaegerResource;
instrumentationLibrarySpans: InstrumentationLibrarySpan[];
}
export interface JaegerResource {
attributes: JaegerAttribute[];
}
export interface InstrumentationLibrarySpan {
instrumentationLibrary: InstrumentationLibrary;
spans: JaegerSpan[];
}
export interface InstrumentationLibrary {
name: string;
version: string;
}
export interface JaegerSpan {
traceId: string;
spanId: string;
parentSpanId: string;
name: string;
kind: string;
startTimeUnixNano: number;
endTimeUnixNano: number;
attributes: JaegerAttribute[];
}
export interface JaegerAttribute {
key: string;
value: JaegerValue;
}
export interface JaegerValue {
stringValue: string;
doubleValue: string;
}
export interface JaegerGroup {
level: number;
startPx: number;
endPx: number;
durationPx: number;
startMs: number;
endMs: number;
durationMs: number;
traceId: string;
spanId: string;
parentSpanId: string;
name: string;
kind: string;
startTimeUnixNano: number;
endTimeUnixNano: number;
attributes: JaegerAttribute[];
children: JaegerGroup[];
}

View File

@@ -289,6 +289,7 @@ export interface RecentCall {
remote_host: string;
direction: string;
trunk: string;
trace_id: string;
}
export interface SpeechCredential {

View File

@@ -0,0 +1,29 @@
import React from "react";
import { RecentCall } from "src/api/types";
export type CallDetailProps = {
call: RecentCall;
};
export const CallDetail = ({ call }: CallDetailProps) => {
return (
<>
<div className="item__details">
<div className="pre-grid">
{Object.keys(call).map((key) => (
<React.Fragment key={key}>
<div>{key}:</div>
<div>
{call[key as keyof typeof call]
? call[key as keyof typeof call].toString()
: "null"}
</div>
</React.Fragment>
))}
</div>
</div>
</>
);
};
export default CallDetail;

View File

@@ -0,0 +1,181 @@
import React, { useEffect, useState } from "react";
import { Bar } from "./jaeger/bar";
import { JaegerGroup, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
import { getJaegerTrace } from "src/api";
import { toastError } from "src/store";
import { RecentCall } from "src/api/types";
import { JaegerDetail } from "./jaeger/detail";
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: 100,
height: 100,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, []);
return windowSize;
}
export type CallTracingProps = {
call: RecentCall;
};
export const CallTracing = ({ call }: CallTracingProps) => {
const [jaegerRoot, setJaegerRoot] = useState<JaegerRoot>();
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
const [jaegerDetail, setJaegerDetail] = useState<JaegerGroup>();
const windowSize = useWindowSize();
const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
setJaegerRoot(trace);
const spans: JaegerSpan[] = [];
trace.resourceSpans.forEach((resourceSpan) => {
resourceSpan.instrumentationLibrarySpans.forEach(
(instrumentationLibrarySpan) => {
instrumentationLibrarySpan.spans.forEach((value) => {
const attrs = value.attributes.filter(
(attr) =>
!(
attr.key.startsWith("telemetry") ||
attr.key.startsWith("internal")
)
);
value.attributes = attrs;
spans.push(value);
});
}
);
});
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
return spans;
};
const getGroupsByParent = (spanId: string, groups: JaegerGroup[]) => {
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
return groups.filter((value) => value.parentSpanId === spanId);
};
const getRootSpan = (spans: JaegerSpan[]) => {
const spanIds = spans.map((value) => value.spanId);
return spans.find((value) => spanIds.indexOf(value.parentSpanId) == -1);
};
const getRootGroup = (grps: JaegerGroup[]) => {
const spanIds = grps.map((value) => value.spanId);
return grps.find((value) => spanIds.indexOf(value.parentSpanId) == -1);
};
const calculateRatio = (span: JaegerSpan) => {
const { innerWidth } = window;
const durationMs =
(span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000;
if (durationMs > innerWidth) {
const offset = innerWidth > 1200 ? 3 : innerWidth > 800 ? 2.5 : 2;
return durationMs / (innerWidth - innerWidth / offset);
}
return 1;
};
const buildSpans = (root: JaegerRoot) => {
const spans = getSpansFromJaegerRoot(root);
const rootSpan = getRootSpan(spans);
if (rootSpan) {
const startTime = rootSpan.startTimeUnixNano;
const ratio = calculateRatio(rootSpan);
calculateRatio(rootSpan);
const groups: JaegerGroup[] = spans.map((span) => {
const level = 0;
const children: JaegerGroup[] = [];
const startMs = (span.startTimeUnixNano - startTime) / 1_000_000;
const durationMs =
(span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000;
const startPx = startMs / ratio;
const durationPx = durationMs / ratio;
const endPx = startPx + durationPx;
const endMs = startMs + durationMs;
return {
level,
children,
startPx,
endPx,
durationPx,
startMs,
endMs,
durationMs,
...span,
};
});
const rootGroup = getRootGroup(groups);
if (rootGroup) {
rootGroup.children = buildChildren(
rootGroup.level + 1,
rootGroup,
groups
);
setJaegerDetail(rootGroup);
setJaegerGroup(rootGroup);
}
}
};
const buildChildren = (
level: number,
rootGroup: JaegerGroup,
groups: JaegerGroup[]
): JaegerGroup[] => {
return getGroupsByParent(rootGroup.spanId, groups).map((group) => {
group.level = level;
group.children = buildChildren(group.level + 1, group, groups);
return group;
});
};
useEffect(() => {
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
getJaegerTrace(call.account_sid, call.trace_id)
.then(({ json }) => {
if (json) {
buildSpans(json);
}
})
.catch((error) => {
toastError(error.msg);
});
}
}, []);
useEffect(() => {
if (jaegerRoot) {
buildSpans(jaegerRoot);
}
}, [windowSize]);
if (jaegerGroup) {
return (
<>
<div className="item__details">
<div className="barGroup">
<Bar group={jaegerGroup} handleRowSelect={setJaegerDetail} />
</div>
</div>
{jaegerDetail && <JaegerDetail group={jaegerDetail} />}
</>
);
}
return null;
};
export default CallTracing;

View File

@@ -4,8 +4,10 @@ import dayjs from "dayjs";
import { Icons } from "src/components";
import { formatPhoneNumber } from "src/utils";
import { PcapButton } from "./pcap";
import type { RecentCall } from "src/api/types";
import { Tabs, Tab } from "@jambonz/ui-kit";
import CallDetail from "./call-detail";
import CallTracing from "./call-tracing";
type DetailsItemProps = {
call: RecentCall;
@@ -13,6 +15,7 @@ type DetailsItemProps = {
export const DetailsItem = ({ call }: DetailsItemProps) => {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState("");
return (
<div className="item">
@@ -55,21 +58,29 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
</div>
</div>
</summary>
<div className="item__details">
<div className="pre-grid">
{Object.keys(call).map((key) => (
<React.Fragment key={key}>
<div>{key}:</div>
<div>
{call[key as keyof typeof call]
? call[key as keyof typeof call].toString()
: "null"}
</div>
</React.Fragment>
))}
{call.trace_id === "00000000000000000000000000000000" ? (
<CallDetail call={call} />
) : (
<Tabs active={[activeTab, setActiveTab]}>
<Tab id="details" label="Details">
<CallDetail call={call} />
</Tab>
<Tab id="tracing" label="Tracing">
<CallTracing call={call} />
</Tab>
</Tabs>
)}
{open && (
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "300px",
}}
>
<PcapButton call={call} />
</div>
{open && <PcapButton call={call} />}
</div>
)}
</details>
</div>
);

View File

@@ -0,0 +1,56 @@
import React from "react";
import { JaegerGroup } from "src/api/jaeger-types";
import "./styles.scss";
import { formattedDuration } from "./utils";
type BarProps = {
group: JaegerGroup;
handleRowSelect: (grp: JaegerGroup) => void;
};
export const Bar = ({ group, handleRowSelect }: BarProps) => {
const titleMargin = group.level * 30;
const handleRowClick = () => {
handleRowSelect(group);
};
const truncate = (str: string) => {
if (str.length > 36) {
return str.substring(0, 36) + "...";
}
return str;
};
return (
<div className="barWrapper">
<div
role="presentation"
className="barWrapper__row"
onClick={handleRowClick}
>
<div
className="barWrapper__header"
style={{ paddingLeft: `${titleMargin}px` }}
>
{truncate(group.name)}
</div>
<button
className="barWrapper__span"
style={{ marginLeft: `${group.startPx}px`, width: group.durationPx }}
/>
<div className="barWrapper__duration">
{formattedDuration(group.durationMs)}
</div>
</div>
{group.children.map((value) => (
<Bar
key={value.spanId}
group={value}
handleRowSelect={handleRowSelect}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,55 @@
import React from "react";
import { JaegerGroup } from "src/api/jaeger-types";
import dayjs from "dayjs";
import "./styles.scss";
import { formattedDuration } from "./utils";
type JaegerDetailProps = {
group: JaegerGroup;
};
export const JaegerDetail = ({ group }: JaegerDetailProps) => {
return (
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__header">Span: {group.name}</div>
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">Span ID:</div>
<div className="spanDetailsWrapper__details_body">{group.spanId}</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">Span Start:</div>
<div className="spanDetailsWrapper__details_body">
{dayjs
.unix(group.startTimeUnixNano / 1000000000)
.format("DD/MM/YY HH:mm:ss.SSS")}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">Span End:</div>
<div className="spanDetailsWrapper__details_body">
{dayjs
.unix(group.endTimeUnixNano / 1000000000)
.format("DD/MM/YY HH:mm:ss.SSS")}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">Duration:</div>
<div className="spanDetailsWrapper__details_body">
{formattedDuration(group.durationMs)}
</div>
</div>
{group.attributes.map((attribute) => (
<div key={attribute.key} className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
{attribute.key}:
</div>
<div className="spanDetailsWrapper__details_body">
{attribute.value.stringValue || attribute.value.doubleValue}
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,234 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
.jaegerModal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
min-height: 100%;
min-width: 100%;
background-color: #fff;
z-index: vars.$zindex00;
overflow-y: hidden;
}
.modalHeader button {
min-height: 28px;
@media (max-width: 820px) {
height: 34px;
}
@media (min-width: 820px) {
height: 40px;
}
}
.modalHeader {
display: flex;
background-color: white;
padding: 20px;
vertical-align: middle;
justify-content: left;
height: 8vh;
@media (max-width: 380px) {
padding: 10px;
height: 10vh;
font-size: 0.9em;
font-weight: lighter;
}
@media (min-width: 410px) {
padding: 10px;
height: 8vh;
font-weight: lighter;
}
@media (min-width: 600px) {
padding: 20px;
font-weight: lighter;
font-size: small;
}
@media (min-width: 820px) {
padding: 20px;
font-weight: lighter;
font-size: small;
}
@media (min-width: 910px) {
font-weight: bolder;
}
&__header_item {
padding-top: 10px;
margin-left: 20px;
display: flex;
font-weight: bolder;
@media (max-width: 380px) {
padding-top: 8px;
margin-left: 15px;
font-size: 0.8em;
}
@media (min-width: 380px) {
padding-top: 12px;
margin-left: 15px;
font-size: 0.7em;
font-weight: lighter;
}
@media (min-width: 410px) {
padding-top: 11px;
margin-left: 15px;
font-size: 0.7em;
font-weight: lighter;
}
@media (min-width: 820px) {
padding-top: 10px;
font-weight: bolder;
font-size: medium;
}
@media (min-width: 910px) {
padding-top: 12px;
font-weight: bolder;
font-size: 1.2em;
}
}
}
.barGroup {
border-radius: ui-vars.$px01;
@include mixins.code();
text-align: left;
padding: ui-vars.$px03;
color: ui-vars.$pink;
background-color: ui-vars.$dark;
border-radius: ui-vars.$px01;
margin-top: ui-vars.$px02;
overflow-x: auto;
overflow-y: scroll;
@media (max-width: 600px) {
padding: 15px;
height: 40vh;
}
}
.barWrapper {
width: 100%;
&__row {
display: flex;
width: 100%;
cursor: pointer;
}
&__row:hover {
font-weight: bolder;
color: ui-vars.$purple;
}
&__row:hover button {
font-weight: bolder;
background-color: ui-vars.$purple;
cursor: pointer;
}
&__header {
min-width: 400px;
height: 15px;
padding-top: 4px;
font-size: small;
@media (max-width: 600px) {
padding-top: 4px;
min-width: 250px;
font-size: x-small;
}
}
&__span {
padding-top: 4px;
height: 5px;
flex-shrink: 0;
vertical-align: middle;
border: 3px solid #444;
border-radius: 8px;
background-color: ui-vars.$jambonz;
min-width: 6px;
}
&__duration {
margin: 5px;
min-width: 150px;
}
}
.spanDetailsWrapper {
border-radius: ui-vars.$px01;
@include mixins.code();
text-align: left;
padding: ui-vars.$px03;
color: ui-vars.$pink;
background-color: ui-vars.$dark;
border-radius: ui-vars.$px01;
margin-top: ui-vars.$px02;
&__detailsWrapper {
height: 100%;
padding: 20px;
font-size: 0.9em;
@media (max-width: 600px) {
padding: 5px;
font-size: 0.7em;
}
@media (min-width: 600px) {
padding: 15px;
font-size: 0.9em;
}
}
&__details {
display: flex;
}
&__details_header {
padding: 5px;
@media (max-width: 600px) {
min-width: 160px;
}
@media (min-width: 600px) {
min-width: 200px;
}
@media (min-width: 900px) {
min-width: 300px;
}
}
&__details_body {
flex-grow: 1;
white-space: pre;
width: 100%;
padding: 5px;
}
&__header {
background-color: #fff;
color: ui-vars.$jambonz;
padding: 20px 20px 20px 30px;
border-bottom: thin solid #ccc;
}
}

View File

@@ -0,0 +1,16 @@
export const formattedDuration = (duration: number) => {
if (duration < 1) {
return (Math.round(duration * 100) / 100).toFixed(2) + "ms";
} else if (duration < 1000) {
return (Math.round(duration * 100) / 100).toFixed(0) + "ms";
} else if (duration >= 1000) {
const min = Math.floor((duration / 1000 / 60) << 0);
if (min == 0) {
const secs = parseFloat(`${duration / 1000}`).toFixed(2);
return `${secs}s`;
} else {
const sec = Math.floor((duration / 1000) % 60);
return `${min}m ${sec}s`;
}
}
};

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { getPcap, getRecentCall } from "src/api";
import { getPcap } from "src/api";
import { toastError } from "src/store";
import type { Pcap, RecentCall } from "src/api/types";
@@ -13,21 +13,13 @@ export const PcapButton = ({ call }: PcapButtonProps) => {
const [pcap, setPcap] = useState<Pcap>();
useEffect(() => {
getRecentCall(call.account_sid, call.sip_callid)
.then(({ json }) => {
if (json.total > 0) {
getPcap(call.account_sid, call.sip_callid)
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call.sip_callid}.pcap`,
});
}
})
.catch((error) => {
toastError(error.msg);
});
getPcap(call.account_sid, call.sip_callid)
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call.sip_callid}.pcap`,
});
}
})
.catch((error) => {

View File

@@ -170,6 +170,7 @@ details {
color: ui-vars.$pink;
background-color: ui-vars.$dark;
border-radius: ui-vars.$px01;
margin-top: ui-vars.$px02;
}
.pcap {