Skip to content

Commit 5f6c9e0

Browse files
authored
Add a simple live trace client and change client url to lazy load (#672)
1 parent e0ed6d0 commit 5f6c9e0

File tree

12 files changed

+431
-76
lines changed

12 files changed

+431
-76
lines changed

sdk/server-proxies/src/HttpServerProxy.ts

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,39 +48,21 @@ export class HttpServerProxy {
4848
return cat.url;
4949
}
5050

51-
public async getLiveTraceUrl(): Promise<string> {
52-
let url = new URL(this.endpoint);
53-
let token: string | undefined;
54-
let toolToken: string | undefined;
51+
public getLiveTraceUrl() : string {
52+
return `${this.endpoint}livetrace`;
53+
}
54+
55+
public async getLiveTraceToken(): Promise<string> {
56+
let token: string;
5557
if (isTokenCredential(this.credential)) {
56-
toolToken = token = (await this.credential.getToken("https://webpubsub.azure.com"))?.token;
58+
return (await this.credential.getToken("https://webpubsub.azure.com"))!.token;
5759
} else {
58-
token = jwt.sign({}, this.credential.key, {
60+
return jwt.sign({}, this.credential.key, {
5961
audience: `${this.endpoint}livetrace`,
6062
expiresIn: "1h",
6163
algorithm: "HS256",
6264
});
63-
toolToken = jwt.sign({}, this.credential.key, {
64-
audience: `${this.endpoint}livetrace/tool`,
65-
expiresIn: "1h",
66-
algorithm: "HS256",
67-
});
68-
}
69-
70-
return `${this.endpoint}livetrace/tool?livetrace_access_token=${token}&access_token=${toolToken}`;
71-
}
72-
73-
private _getUrl(path: string, query?: Record<string, string> | undefined): string {
74-
const baseUrl = "https://host";
75-
const url = new URL(baseUrl);
76-
url.pathname = path;
77-
url.searchParams.append("api-version", apiVersion);
78-
if (query) {
79-
for (const key in query) {
80-
url.searchParams.append(key, query[key]);
81-
}
8265
}
83-
return url.toString();
8466
}
8567

8668
private async sendHttpRequest(request: TunnelIncomingMessage, options?: RunOptions, abortSignal?: AbortSignalLike): Promise<TunnelOutgoingMessage> {

tools/awps-tunnel/client/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"dependencies": {
66
"@fluentui/react": "^8.106.3",
77
"@fluentui/react-components": "^9.34.1",
8+
"@microsoft/signalr": "^8.0.0",
89
"@popperjs/core": "2.11.8",
910
"bootstrap": "^5.1.3",
1011
"http-proxy-middleware": "^2.0.6",
@@ -24,6 +25,7 @@
2425
"rimraf": "^3.0.2",
2526
"scheduler": "^0.20.0",
2627
"socket.io-client": "^4.7.2",
28+
"use-immer": "^0.9.0",
2729
"web-vitals": "^2.1.4",
2830
"workbox-background-sync": "^6.5.3",
2931
"workbox-broadcast-update": "^6.5.3",
@@ -46,12 +48,12 @@
4648
"@testing-library/jest-dom": "^5.17.0",
4749
"@testing-library/react": "^13.4.0",
4850
"@testing-library/user-event": "^13.5.0",
49-
"@typescript-eslint/typescript-estree": "^6.12.0",
5051
"@types/jest": "^29.5.5",
5152
"@types/markdown-it": "^13.0.2",
5253
"@types/node": "^16.18.53",
5354
"@types/react": "^18.2.22",
5455
"@types/react-dom": "^18.2.7",
56+
"@typescript-eslint/typescript-estree": "^6.12.0",
5557
"ajv": "^8.11.0",
5658
"cross-env": "^7.0.3",
5759
"eslint": "^8.18.0",

tools/awps-tunnel/client/src/components/Dashboard.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,14 @@ export const Dashboard = () => {
7979
EventHandler({ hub: data.hub, settings: data.serviceConfiguration }),
8080
],
8181
status: data?.tunnelConnectionStatus,
82-
content: <ServicePanel endpoint={data.endpoint} status={data.tunnelConnectionStatus} liveTraceUrl={data.liveTraceUrl}></ServicePanel>,
82+
content: (
83+
<ServicePanel
84+
endpoint={data.endpoint}
85+
status={data.tunnelConnectionStatus}
86+
liveTraceUrl={data.liveTraceUrl}
87+
tokenGenerator={() => dataFetcher.invoke("generateLiveTraceToken")}
88+
></ServicePanel>
89+
),
8390
},
8491
{
8592
key: "proxy",
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useRef } from "react";
2+
import * as signalR from "@microsoft/signalr";
3+
import { useImmer } from "use-immer";
4+
import { ConnectionStatus } from "../models";
5+
import { Tooltip, DataGridBody, DataGridRow, DataGrid, DataGridHeader, DataGridHeaderCell, DataGridCell, TableCellLayout, TableColumnDefinition, createTableColumn } from "@fluentui/react-components";
6+
import { StatusIndicator } from "./workflows/StatusIndicator";
7+
8+
function LiveTraceGrid(props: { headers: Record<string, string>; items: LogDataViewModel[] }) {
9+
// record key as columnId, value as column name
10+
function columns(items: Record<string, string>): TableColumnDefinition<LogDataViewModel>[] {
11+
return Object.entries(items).map(([key, value]) =>
12+
createTableColumn<LogDataViewModel>({
13+
columnId: key,
14+
compare: (a, b) => {
15+
return (a.columns[key] ?? "").localeCompare(b.columns[key] ?? "");
16+
},
17+
renderHeaderCell: () => {
18+
return value;
19+
},
20+
renderCell: (item) => {
21+
const content = item.columns[key];
22+
// showing tooltip if content is long
23+
return (
24+
<TableCellLayout truncate>
25+
{content?.length > 18 ? (
26+
<Tooltip positioning="above-start" content={content} relationship="description">
27+
<span>{content}</span>
28+
</Tooltip>
29+
) : (
30+
<>{content}</>
31+
)}
32+
</TableCellLayout>
33+
);
34+
},
35+
}),
36+
);
37+
}
38+
return (
39+
<DataGrid items={props.items} columns={columns(props.headers)} sortable resizableColumns focusMode="composite">
40+
<DataGridHeader>
41+
<DataGridRow>{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}</DataGridRow>
42+
</DataGridHeader>
43+
<DataGridBody<LogDataViewModel>>
44+
{({ item }) => <DataGridRow<LogDataViewModel> key={item.eventId.toString()}>{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}</DataGridRow>}
45+
</DataGridBody>
46+
</DataGrid>
47+
);
48+
}
49+
50+
enum LogLevel {
51+
Trace,
52+
Debug,
53+
Information,
54+
Warning,
55+
Error,
56+
Critical,
57+
None,
58+
}
59+
60+
interface LiveTraceLogProperty {
61+
eventId: number;
62+
eventName: string;
63+
template: string;
64+
logLevel: LogLevel;
65+
}
66+
67+
// time, eventId, eventName, logContext as columns, template + value, exceptionMessage
68+
interface LiveTraceLogData {
69+
time: string;
70+
eventId: number;
71+
/**
72+
* This field provides the additional information useful for the log item.
73+
*/
74+
logContext: Record<string, string>;
75+
/**
76+
* This field is used to fill the log message's template.
77+
*/
78+
values: string[];
79+
/**
80+
* This field is the exception message that includes in the log item if any.
81+
*/
82+
exceptionMessage: string | undefined;
83+
}
84+
85+
interface LogDataViewModel {
86+
columns: Record<string, string>;
87+
data: LiveTraceLogData;
88+
eventId: number;
89+
}
90+
91+
interface StatusDetail {
92+
status: ConnectionStatus;
93+
message: string;
94+
level: LogLevel;
95+
}
96+
97+
export function LiveTraceSection({ url, tokenGenerator }: { url: string; tokenGenerator(): Promise<string> }) {
98+
const connectionRef = useRef<signalR.HubConnection | undefined>(undefined);
99+
const logProps: Record<number, LiveTraceLogProperty> = {};
100+
101+
const [logItems, updateLogItems] = useImmer<LogDataViewModel[]>([]);
102+
const [headers, updateHeaders] = useImmer<Record<string, string>>({
103+
time: "Time",
104+
eventId: "EventId",
105+
eventName: "EventName",
106+
message: "Event", // template + value
107+
// + dynamic logContext as columns
108+
// + exception as column if exceptionMessage is not empty
109+
});
110+
111+
function getViewModel(data: LiveTraceLogData, template: LiveTraceLogProperty | undefined): LogDataViewModel {
112+
const model: LogDataViewModel = {
113+
data: data,
114+
columns: {
115+
...data.logContext,
116+
time: data.time,
117+
eventId: data.eventId.toString(),
118+
},
119+
eventId: data.eventId,
120+
};
121+
if (data.exceptionMessage) {
122+
// only add exception column when needed
123+
model.columns["exception"] = data.exceptionMessage;
124+
}
125+
126+
// if template is yet undefined, placeholding the message
127+
if (template) {
128+
updateViewModel(model, template);
129+
}
130+
131+
return model;
132+
}
133+
134+
function updateViewModel(model: LogDataViewModel, template: LiveTraceLogProperty): void {
135+
model.columns.eventName = template.eventName;
136+
model.columns.message = template.template.replace(/{(\w+)}/g, (match) => {
137+
return model.data.values.shift() ?? match;
138+
});
139+
}
140+
141+
function updateColumnHeaders(data: LiveTraceLogData) {
142+
// update header incase new column is added from logContext
143+
updateHeaders((h) => {
144+
Object.entries(data.logContext).forEach(([key, _]) => {
145+
if (!h[key]) {
146+
// display name is the key
147+
h[key] = key;
148+
}
149+
});
150+
});
151+
}
152+
153+
const [status, updateStatus] = useImmer<StatusDetail>({ status: ConnectionStatus.Disconnected, message: "", level: LogLevel.None });
154+
155+
const connect = () => {
156+
if (connectionRef.current) {
157+
return connectionRef.current;
158+
}
159+
160+
const connection = new signalR.HubConnectionBuilder()
161+
.withUrl(url, {
162+
accessTokenFactory: tokenGenerator,
163+
skipNegotiation: true,
164+
transport: signalR.HttpTransportType.WebSockets,
165+
})
166+
.withAutomaticReconnect({
167+
nextRetryDelayInMilliseconds: () => 3000,
168+
})
169+
.configureLogging(signalR.LogLevel.Information)
170+
.build();
171+
172+
const startListeningToLogEvents = () => {
173+
connection.send("startListeningToLogEvents").catch((err) => {
174+
console.error(err);
175+
});
176+
};
177+
connectionRef.current = connection;
178+
179+
connection.on("logEvent", (logEvent: LiveTraceLogData) => {
180+
const template = logProps[logEvent.eventId];
181+
if (!template) {
182+
// get messageTemplate from logProps and show the log item
183+
connection.send("LogProperty", logEvent.eventId);
184+
}
185+
// incase logcontext contains more columns
186+
updateColumnHeaders(logEvent);
187+
// request for messageTemplate and then render the log item
188+
updateLogItems((i) => {
189+
i.unshift(getViewModel(logEvent, template));
190+
});
191+
});
192+
193+
connection.on("LogProperty", (props: LiveTraceLogProperty) => {
194+
// only set when the event template is not yet set
195+
if (!logProps[props.eventId]) {
196+
logProps[props.eventId] = props;
197+
updateLogItems((i) => {
198+
const item = i.find((a) => a.eventId === props.eventId);
199+
if (item) {
200+
updateViewModel(item, props);
201+
}
202+
});
203+
}
204+
});
205+
connection.onclose((err) => {
206+
console.error(err);
207+
updateStatus((s) => {
208+
s.status = ConnectionStatus.Disconnected;
209+
s.message = err ? err.message : "Connection closed";
210+
s.level = LogLevel.Error;
211+
});
212+
});
213+
connection.onreconnected(() => {
214+
updateStatus((s) => {
215+
s.status = ConnectionStatus.Connected;
216+
s.message = "Connection reconnected";
217+
s.level = LogLevel.Information;
218+
});
219+
});
220+
connection
221+
.start()
222+
.then(() => {
223+
console.log("Connected, start listening to live traces");
224+
startListeningToLogEvents();
225+
updateStatus((s) => {
226+
s.status = ConnectionStatus.Connected;
227+
s.message = "Connected, start listening to live traces";
228+
s.level = LogLevel.Information;
229+
});
230+
})
231+
.catch((err) => {
232+
console.error(err);
233+
updateStatus((s) => {
234+
s.status = ConnectionStatus.Disconnected;
235+
s.message = err ? err.message : "Connection closed";
236+
s.level = LogLevel.Error;
237+
});
238+
});
239+
};
240+
241+
connect();
242+
243+
return (
244+
<>
245+
<div className="m-2">
246+
Status: <StatusIndicator status={status.status}></StatusIndicator> {status.message}
247+
</div>
248+
<LiveTraceGrid headers={headers} items={logItems} />
249+
</>
250+
);
251+
}

0 commit comments

Comments
 (0)