acinguiux-dnr-utils 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/package.json +3 -22
- package/src/authentication/auth.ts +1 -7
- package/src/authentication/callbacks.ts +3 -15
- package/src/authentication/provider.ts +1 -3
- package/src/loggers/auth-fetch.ts +20 -18
- package/src/application-insights/index.ts +0 -37
- package/src/instrumentation/browser.test.ts +0 -118
- package/src/instrumentation/browser.ts +0 -24
- package/src/instrumentation/node.test.ts +0 -63
- package/src/instrumentation/node.ts +0 -81
- package/src/loggers/auth-fetch.test.ts +0 -383
package/README.md
CHANGED
|
@@ -29,7 +29,12 @@ From the application layout it can be integrated like
|
|
|
29
29
|
<html lang="en">
|
|
30
30
|
<head>
|
|
31
31
|
<link rel="icon" href="favicon.ico" />
|
|
32
|
-
<link rel="
|
|
32
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
33
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
34
|
+
<link
|
|
35
|
+
rel="stylesheet"
|
|
36
|
+
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
|
37
|
+
/>
|
|
33
38
|
</head>
|
|
34
39
|
<body>
|
|
35
40
|
<Application session={session} aiConnectionString={process.env.INSIGHTS_CONNECTION_STRING} sidebar={hasAccess}>
|
package/package.json
CHANGED
|
@@ -1,42 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acinguiux-dnr-utils",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"@auth/core": "^0.41.1",
|
|
6
|
-
"
|
|
7
|
-
"@microsoft/applicationinsights-clickanalytics-js": "^3.3.10",
|
|
8
|
-
"@microsoft/applicationinsights-react-js": "^17.3.6",
|
|
9
|
-
"@microsoft/applicationinsights-web": "^3.3.10",
|
|
10
|
-
"@opentelemetry/core": "^2.2.0",
|
|
11
|
-
"@opentelemetry/exporter-logs-otlp-http": "^0.204.0",
|
|
12
|
-
"@opentelemetry/exporter-metrics-otlp-http": "^0.202.0",
|
|
13
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
|
14
|
-
"@opentelemetry/instrumentation-http": "^0.203.0",
|
|
15
|
-
"@opentelemetry/instrumentation-undici": "^0.13.2",
|
|
16
|
-
"@opentelemetry/instrumentation-winston": "^0.50.2",
|
|
17
|
-
"@opentelemetry/resources": "^2.2.0",
|
|
18
|
-
"@opentelemetry/sdk-node": "^0.203.0",
|
|
19
|
-
"@opentelemetry/semantic-conventions": "^1.37.0",
|
|
20
|
-
"@opentelemetry/winston-transport": "^0.16.2",
|
|
21
|
-
"next": "14.2.35",
|
|
22
|
-
"winston": "^3.18.3"
|
|
6
|
+
"next": "14.2.35"
|
|
23
7
|
},
|
|
24
8
|
"devDependencies": {
|
|
25
9
|
"acinguiux-dnr-typescript": "0.0.1",
|
|
26
|
-
"acinguiux-dnr-vitest": "0.0.
|
|
10
|
+
"acinguiux-dnr-vitest": "0.0.2",
|
|
27
11
|
"@types/node": "^22.19.0",
|
|
28
12
|
"@types/react": "^18.0.0"
|
|
29
13
|
},
|
|
30
14
|
"exports": {
|
|
31
|
-
"./application-insights": "./src/application-insights/index.ts",
|
|
32
15
|
"./authentication": "./src/authentication/index.ts",
|
|
33
16
|
"./authentication/middleware": "./src/authentication/middleware.ts",
|
|
34
17
|
"./authentication/client-auth-guard": "./src/authentication/client-auth-guard.ts",
|
|
35
18
|
"./ms-graph": "./src/ms-graph/index.ts",
|
|
36
19
|
"./formatters": "./src/formatters/index.ts",
|
|
37
20
|
"./mappers": "./src/mappers/index.ts",
|
|
38
|
-
"./instrumentation/node": "./src/instrumentation/node.ts",
|
|
39
|
-
"./instrumentation/browser": "./src/instrumentation/browser.ts",
|
|
40
21
|
"./loggers": "./src/loggers/index.ts"
|
|
41
22
|
},
|
|
42
23
|
"peerDependencies": {
|
|
@@ -4,17 +4,11 @@ import type { Session } from "next-auth";
|
|
|
4
4
|
import { callbacks } from "./callbacks";
|
|
5
5
|
import PingID from "./provider";
|
|
6
6
|
|
|
7
|
-
type AuthSession = Session & {
|
|
8
|
-
expires_at?: number;
|
|
9
|
-
access_token?: string;
|
|
10
|
-
refresh_token?: string;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
7
|
const isAuthDisabled = ["true", "1", "yes"].includes(
|
|
14
8
|
(process.env.AUTH_DISABLED || "").toLowerCase(),
|
|
15
9
|
);
|
|
16
10
|
|
|
17
|
-
const mockSession:
|
|
11
|
+
const mockSession: Session = {
|
|
18
12
|
user: {
|
|
19
13
|
name: "Local User",
|
|
20
14
|
email: "local.user@example.com",
|
|
@@ -2,19 +2,7 @@ import type { NextRequest } from "next/server.js";
|
|
|
2
2
|
import type { Account, Session } from "next-auth";
|
|
3
3
|
import type { JWT } from "next-auth/jwt";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
expires_at?: number;
|
|
7
|
-
access_token?: string;
|
|
8
|
-
refresh_token?: string;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
type AuthSession = Session & {
|
|
12
|
-
expires_at?: number;
|
|
13
|
-
access_token?: string;
|
|
14
|
-
refresh_token?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
async function jwt({ token, account }: { token: AuthJWT; account?: Account | null }) {
|
|
5
|
+
async function jwt({ token, account }: { token: JWT; account?: Account | null }) {
|
|
18
6
|
// The first time jwt is called, account exists. We map fields from account to token
|
|
19
7
|
// which is passed to the session callback. User is also available in this callback which
|
|
20
8
|
// comes from the profile function in the provider. We can use this in the future to surface
|
|
@@ -58,7 +46,7 @@ async function jwt({ token, account }: { token: AuthJWT; account?: Account | nul
|
|
|
58
46
|
return token;
|
|
59
47
|
}
|
|
60
48
|
|
|
61
|
-
async function session({ session, token }: { session:
|
|
49
|
+
async function session({ session, token }: { session: Session; token: JWT }) {
|
|
62
50
|
// We map fields from token to make them available in the session.
|
|
63
51
|
session.expires_at = token.expires_at;
|
|
64
52
|
session.access_token = token.access_token;
|
|
@@ -66,7 +54,7 @@ async function session({ session, token }: { session: AuthSession; token: AuthJW
|
|
|
66
54
|
return session;
|
|
67
55
|
}
|
|
68
56
|
|
|
69
|
-
async function authorized({ auth }: { request: NextRequest; auth:
|
|
57
|
+
async function authorized({ auth }: { request: NextRequest; auth: Session | null }) {
|
|
70
58
|
// If there is no access token, the user is not authorized. Returning false from this
|
|
71
59
|
// callback will redirect the user to the sign in page.
|
|
72
60
|
|
|
@@ -36,9 +36,7 @@ export default function PingID(
|
|
|
36
36
|
// the request and decode the JWT instead to get the user profile
|
|
37
37
|
|
|
38
38
|
async request({ tokens }: { tokens: JWT }) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return extractProfile(accessToken);
|
|
39
|
+
return extractProfile(tokens.access_token);
|
|
42
40
|
},
|
|
43
41
|
},
|
|
44
42
|
// Profile is returned from the userinfo request
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
declare type RequestInit = any;
|
|
4
4
|
|
|
5
5
|
import { auth } from "../authentication";
|
|
6
|
-
import { logger } from "../instrumentation/node";
|
|
6
|
+
// import { logger } from "../instrumentation/node";
|
|
7
7
|
|
|
8
8
|
export const authFetch = async (
|
|
9
9
|
url: string,
|
|
@@ -49,17 +49,18 @@ export const authFetch = async (
|
|
|
49
49
|
errorText = data || errorText;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
logger.error(`API request failed for (${humanReadablePath})`, {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
52
|
+
// logger.error(`API request failed for (${humanReadablePath})`, {
|
|
53
|
+
// path: pathname,
|
|
54
|
+
// params: searchParams.toString(),
|
|
55
|
+
// status: responseClone.status,
|
|
56
|
+
// statusText: responseClone.statusText,
|
|
57
|
+
// detail: errorText,
|
|
58
|
+
// user: {
|
|
59
|
+
// name: session?.user?.name || "Anonymous",
|
|
60
|
+
// email: session?.user?.email || "anonymous@shell.com",
|
|
61
|
+
// },
|
|
62
|
+
// });
|
|
63
|
+
console.log('error', errorText);
|
|
63
64
|
|
|
64
65
|
if (throwOnError) {
|
|
65
66
|
throw new Error(errorText);
|
|
@@ -68,12 +69,13 @@ export const authFetch = async (
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
logger.info(`API request succeeded for ${humanReadablePath}`, {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
});
|
|
72
|
+
// logger.info(`API request succeeded for ${humanReadablePath}`, {
|
|
73
|
+
// path: pathname,
|
|
74
|
+
// params: searchParams.toString(),
|
|
75
|
+
// status: response.status,
|
|
76
|
+
// statusText: response.statusText,
|
|
77
|
+
// });
|
|
78
|
+
console.log('info')
|
|
77
79
|
|
|
78
80
|
return response;
|
|
79
81
|
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { ClickAnalyticsPlugin } from "@microsoft/applicationinsights-clickanalytics-js";
|
|
2
|
-
import { ReactPlugin } from "@microsoft/applicationinsights-react-js";
|
|
3
|
-
import { ApplicationInsights, type Snippet } from "@microsoft/applicationinsights-web";
|
|
4
|
-
import type { Session } from "next-auth";
|
|
5
|
-
|
|
6
|
-
export let applicationInsights: ApplicationInsights | undefined;
|
|
7
|
-
|
|
8
|
-
export const initApplicationInsights = (connectionString?: string, user?: Session["user"]) => {
|
|
9
|
-
if (!connectionString) return;
|
|
10
|
-
if (applicationInsights) return;
|
|
11
|
-
if (typeof window === "undefined") return;
|
|
12
|
-
|
|
13
|
-
const reactPlugin = new ReactPlugin();
|
|
14
|
-
const clickAnalyticsPlugin = new ClickAnalyticsPlugin();
|
|
15
|
-
const config: Snippet["config"] = {
|
|
16
|
-
connectionString,
|
|
17
|
-
enableCorsCorrelation: true,
|
|
18
|
-
enableAjaxPerfTracking: true,
|
|
19
|
-
enableAutoRouteTracking: false,
|
|
20
|
-
enableRequestHeaderTracking: true,
|
|
21
|
-
enableResponseHeaderTracking: true,
|
|
22
|
-
isBrowserLinkTrackingEnabled: true,
|
|
23
|
-
extensions: [reactPlugin, clickAnalyticsPlugin] as Snippet["config"]["extensions"],
|
|
24
|
-
extensionConfig: {
|
|
25
|
-
[clickAnalyticsPlugin.identifier]: { autoCapture: true },
|
|
26
|
-
},
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
applicationInsights = new ApplicationInsights({ config });
|
|
30
|
-
|
|
31
|
-
applicationInsights.loadAppInsights();
|
|
32
|
-
applicationInsights.setAuthenticatedUserContext(
|
|
33
|
-
user?.email || "anonymous@shell.com",
|
|
34
|
-
undefined,
|
|
35
|
-
true,
|
|
36
|
-
);
|
|
37
|
-
};
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
// Mock only Grafana Faro SDK
|
|
4
|
-
const mockInitializeFaro = vi.fn();
|
|
5
|
-
const mockGetWebInstrumentations = vi.fn(() => []);
|
|
6
|
-
const mockFaro = { api: null };
|
|
7
|
-
|
|
8
|
-
vi.mock("@grafana/faro-web-sdk", () => ({
|
|
9
|
-
faro: mockFaro,
|
|
10
|
-
initializeFaro: mockInitializeFaro,
|
|
11
|
-
getWebInstrumentations: mockGetWebInstrumentations,
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
describe("browser instrumentation", () => {
|
|
15
|
-
const originalEnv = process.env.NEXT_PUBLIC_FARO_URL;
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
vi.clearAllMocks();
|
|
19
|
-
delete process.env.NEXT_PUBLIC_FARO_URL;
|
|
20
|
-
mockFaro.api = null;
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
if (originalEnv) {
|
|
25
|
-
process.env.NEXT_PUBLIC_FARO_URL = originalEnv;
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("should return null when faro.api is already initialized", async () => {
|
|
30
|
-
mockFaro.api = {} as any;
|
|
31
|
-
process.env.NEXT_PUBLIC_FARO_URL = "https://faro.example.com";
|
|
32
|
-
|
|
33
|
-
const { instrument } = await import("./browser");
|
|
34
|
-
const result = instrument("test-product", { name: "Test", email: "test@example.com" });
|
|
35
|
-
|
|
36
|
-
expect(result).toBeNull();
|
|
37
|
-
expect(mockInitializeFaro).not.toHaveBeenCalled();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("should return null when NEXT_PUBLIC_FARO_URL is not set", async () => {
|
|
41
|
-
delete process.env.NEXT_PUBLIC_FARO_URL;
|
|
42
|
-
|
|
43
|
-
const { instrument } = await import("./browser");
|
|
44
|
-
const result = instrument("test-product", { name: "Test", email: "test@example.com" });
|
|
45
|
-
|
|
46
|
-
expect(result).toBeNull();
|
|
47
|
-
expect(mockInitializeFaro).not.toHaveBeenCalled();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("should initialize Faro when NEXT_PUBLIC_FARO_URL is set and faro.api is null", async () => {
|
|
51
|
-
process.env.NEXT_PUBLIC_FARO_URL = "https://faro.example.com";
|
|
52
|
-
|
|
53
|
-
const { instrument } = await import("./browser");
|
|
54
|
-
instrument("my-product", { name: "John Doe", email: "john@example.com" });
|
|
55
|
-
|
|
56
|
-
expect(mockInitializeFaro).toHaveBeenCalledWith({
|
|
57
|
-
url: "https://faro.example.com/collect",
|
|
58
|
-
app: {
|
|
59
|
-
name: "my-product-browser",
|
|
60
|
-
namespace: "geneva-my-product",
|
|
61
|
-
},
|
|
62
|
-
user: {
|
|
63
|
-
email: "john@example.com",
|
|
64
|
-
fullName: "John Doe",
|
|
65
|
-
},
|
|
66
|
-
pageTracking: {
|
|
67
|
-
generatePageId: expect.any(Function),
|
|
68
|
-
},
|
|
69
|
-
trackGeolocation: true,
|
|
70
|
-
instrumentations: [],
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("should handle user with empty email", async () => {
|
|
75
|
-
process.env.NEXT_PUBLIC_FARO_URL = "https://faro.example.com";
|
|
76
|
-
|
|
77
|
-
const { instrument } = await import("./browser");
|
|
78
|
-
instrument("test-product", { email: undefined, name: "Test User" });
|
|
79
|
-
|
|
80
|
-
expect(mockInitializeFaro).toHaveBeenCalledWith(
|
|
81
|
-
expect.objectContaining({
|
|
82
|
-
user: {
|
|
83
|
-
email: "",
|
|
84
|
-
fullName: "Test User",
|
|
85
|
-
},
|
|
86
|
-
}),
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("should handle user with empty name", async () => {
|
|
91
|
-
process.env.NEXT_PUBLIC_FARO_URL = "https://faro.example.com";
|
|
92
|
-
|
|
93
|
-
const { instrument } = await import("./browser");
|
|
94
|
-
instrument("test-product", { email: "test@example.com", name: undefined });
|
|
95
|
-
|
|
96
|
-
expect(mockInitializeFaro).toHaveBeenCalledWith(
|
|
97
|
-
expect.objectContaining({
|
|
98
|
-
user: {
|
|
99
|
-
email: "test@example.com",
|
|
100
|
-
fullName: "",
|
|
101
|
-
},
|
|
102
|
-
}),
|
|
103
|
-
);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("should create correct page ID from URL", async () => {
|
|
107
|
-
process.env.NEXT_PUBLIC_FARO_URL = "https://faro.example.com";
|
|
108
|
-
|
|
109
|
-
const { instrument } = await import("./browser");
|
|
110
|
-
instrument("test-product", { email: "test@example.com", name: "Test" });
|
|
111
|
-
|
|
112
|
-
const call = mockInitializeFaro.mock.calls[0][0];
|
|
113
|
-
const generatePageId = call.pageTracking.generatePageId;
|
|
114
|
-
|
|
115
|
-
expect(generatePageId({ pathname: "/home" })).toBe("/home");
|
|
116
|
-
expect(generatePageId({ pathname: "/about" })).toBe("/about");
|
|
117
|
-
});
|
|
118
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { faro, getWebInstrumentations, initializeFaro } from "@grafana/faro-web-sdk";
|
|
2
|
-
import type { Session } from "next-auth";
|
|
3
|
-
|
|
4
|
-
export const instrument = (productName: string, user: Session["user"]) => {
|
|
5
|
-
if (faro.api) return null;
|
|
6
|
-
if (!process.env.NEXT_PUBLIC_FARO_URL) return null;
|
|
7
|
-
|
|
8
|
-
initializeFaro({
|
|
9
|
-
url: `${process.env.NEXT_PUBLIC_FARO_URL}/collect`,
|
|
10
|
-
app: {
|
|
11
|
-
name: `${productName}-browser`,
|
|
12
|
-
namespace: `geneva-${productName}`,
|
|
13
|
-
},
|
|
14
|
-
user: {
|
|
15
|
-
email: user?.email || "",
|
|
16
|
-
fullName: user?.name || "",
|
|
17
|
-
},
|
|
18
|
-
pageTracking: {
|
|
19
|
-
generatePageId: (url) => url.pathname,
|
|
20
|
-
},
|
|
21
|
-
trackGeolocation: true,
|
|
22
|
-
instrumentations: [...getWebInstrumentations()],
|
|
23
|
-
});
|
|
24
|
-
};
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
// Mock only the heavy external dependencies that prevent module loading
|
|
4
|
-
vi.mock("@opentelemetry/winston-transport", () => ({
|
|
5
|
-
OpenTelemetryTransportV3: vi.fn(),
|
|
6
|
-
}));
|
|
7
|
-
|
|
8
|
-
vi.mock("winston", () => ({
|
|
9
|
-
createLogger: vi.fn(() => ({
|
|
10
|
-
info: vi.fn(),
|
|
11
|
-
error: vi.fn(),
|
|
12
|
-
warn: vi.fn(),
|
|
13
|
-
})),
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
describe("node instrumentation", () => {
|
|
17
|
-
const originalEnv = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
if (originalEnv) {
|
|
25
|
-
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = originalEnv;
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("should export an instrument function", async () => {
|
|
30
|
-
const { instrument } = await import("./node");
|
|
31
|
-
expect(typeof instrument).toBe("function");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("should export a logger", async () => {
|
|
35
|
-
const { logger } = await import("./node");
|
|
36
|
-
expect(logger).toBeDefined();
|
|
37
|
-
expect(typeof logger.info).toBe("function");
|
|
38
|
-
expect(typeof logger.error).toBe("function");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("should return early when OTEL_EXPORTER_OTLP_ENDPOINT is not set", async () => {
|
|
42
|
-
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
43
|
-
|
|
44
|
-
const { instrument } = await import("./node");
|
|
45
|
-
const result = instrument("test-product");
|
|
46
|
-
|
|
47
|
-
expect(result).toBeUndefined();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("should accept product name parameter without throwing", async () => {
|
|
51
|
-
const { instrument } = await import("./node");
|
|
52
|
-
expect(() => instrument("service-name")).not.toThrow();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("should initialize SDK when OTEL_EXPORTER_OTLP_ENDPOINT is set", async () => {
|
|
56
|
-
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.example.com";
|
|
57
|
-
|
|
58
|
-
const { instrument } = await import("./node");
|
|
59
|
-
|
|
60
|
-
// Should not throw when endpoint is configured
|
|
61
|
-
expect(() => instrument("test-service")).not.toThrow();
|
|
62
|
-
});
|
|
63
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/** biome-ignore-all lint/suspicious/noExplicitAny: supress noExplicitAny */
|
|
2
|
-
|
|
3
|
-
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
4
|
-
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
5
|
-
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
6
|
-
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
7
|
-
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
|
8
|
-
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
|
|
9
|
-
import { WinstonInstrumentation } from "@opentelemetry/instrumentation-winston";
|
|
10
|
-
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
11
|
-
import { api, logs, metrics, NodeSDK, node as traces } from "@opentelemetry/sdk-node";
|
|
12
|
-
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
13
|
-
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
|
|
14
|
-
import { createLogger } from "winston";
|
|
15
|
-
|
|
16
|
-
export const logger = createLogger({
|
|
17
|
-
transports: [new OpenTelemetryTransportV3()],
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
class DisableExtractContextPropagator implements api.TextMapPropagator {
|
|
21
|
-
constructor(private readonly propagator: W3CTraceContextPropagator) {}
|
|
22
|
-
inject = (context: api.Context, carrier: any, setter = api.defaultTextMapSetter) =>
|
|
23
|
-
this.propagator.inject(context, carrier, setter);
|
|
24
|
-
extract = (context: api.Context, _carrier: any, _getter = api.defaultTextMapGetter) => context;
|
|
25
|
-
fields = () => this.propagator.fields();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const instrument = (productName: string) => {
|
|
29
|
-
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
|
30
|
-
|
|
31
|
-
api.propagation.setGlobalPropagator(
|
|
32
|
-
new DisableExtractContextPropagator(new W3CTraceContextPropagator()),
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
const sdk = new NodeSDK({
|
|
36
|
-
resource: resourceFromAttributes({
|
|
37
|
-
[ATTR_SERVICE_NAME]: `${productName}-ui`,
|
|
38
|
-
}),
|
|
39
|
-
metricReader: new metrics.PeriodicExportingMetricReader({
|
|
40
|
-
exporter: new OTLPMetricExporter({
|
|
41
|
-
url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/metrics`,
|
|
42
|
-
}),
|
|
43
|
-
exportIntervalMillis: 10000,
|
|
44
|
-
}),
|
|
45
|
-
spanProcessors: [
|
|
46
|
-
new traces.BatchSpanProcessor(
|
|
47
|
-
new OTLPTraceExporter({
|
|
48
|
-
url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
|
49
|
-
}),
|
|
50
|
-
),
|
|
51
|
-
],
|
|
52
|
-
logRecordProcessors: [
|
|
53
|
-
new logs.BatchLogRecordProcessor(
|
|
54
|
-
new OTLPLogExporter({
|
|
55
|
-
url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/logs`,
|
|
56
|
-
}),
|
|
57
|
-
),
|
|
58
|
-
],
|
|
59
|
-
instrumentations: [
|
|
60
|
-
new HttpInstrumentation({
|
|
61
|
-
ignoreIncomingRequestHook: (request) => {
|
|
62
|
-
const ignoreUrls = ["/_next/static", "/_next/image", "/favicon.ico", "/api/auth"];
|
|
63
|
-
|
|
64
|
-
return ignoreUrls.some((url) => request.url?.includes(url));
|
|
65
|
-
},
|
|
66
|
-
}),
|
|
67
|
-
new UndiciInstrumentation({
|
|
68
|
-
ignoreRequestHook: (request) => {
|
|
69
|
-
const ignoreUrls = ["/v1/traces"];
|
|
70
|
-
|
|
71
|
-
return ignoreUrls.some((url) => request.path?.includes(url));
|
|
72
|
-
},
|
|
73
|
-
}),
|
|
74
|
-
new WinstonInstrumentation({
|
|
75
|
-
disableLogSending: true,
|
|
76
|
-
}),
|
|
77
|
-
],
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
sdk.start();
|
|
81
|
-
};
|
|
@@ -1,383 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import * as auth_module from "../authentication";
|
|
3
|
-
import * as logger_module from "../instrumentation/node";
|
|
4
|
-
import { authFetch } from "./auth-fetch";
|
|
5
|
-
|
|
6
|
-
vi.mock("../authentication", () => ({
|
|
7
|
-
auth: vi.fn(() => ({
|
|
8
|
-
access_token: "test-token",
|
|
9
|
-
user: { name: "Test User", email: "test@example.com" },
|
|
10
|
-
})),
|
|
11
|
-
}));
|
|
12
|
-
|
|
13
|
-
vi.mock("../instrumentation/node", () => ({
|
|
14
|
-
logger: {
|
|
15
|
-
error: vi.fn(),
|
|
16
|
-
info: vi.fn(),
|
|
17
|
-
},
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
describe("authFetch", () => {
|
|
21
|
-
const domain = "https://api.test.com";
|
|
22
|
-
const path = "test/path";
|
|
23
|
-
const queryParams = "key=value";
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
vi.restoreAllMocks();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const makeJsonResponse = (
|
|
30
|
-
data: unknown,
|
|
31
|
-
{
|
|
32
|
-
ok = true,
|
|
33
|
-
status = 200,
|
|
34
|
-
statusText = "OK",
|
|
35
|
-
contentType = "application/json",
|
|
36
|
-
}: {
|
|
37
|
-
ok?: boolean;
|
|
38
|
-
status?: number;
|
|
39
|
-
statusText?: string;
|
|
40
|
-
contentType?: string;
|
|
41
|
-
} = {},
|
|
42
|
-
) => {
|
|
43
|
-
const response: any = {
|
|
44
|
-
ok,
|
|
45
|
-
json: () => Promise.resolve(data),
|
|
46
|
-
headers: new Headers({ "content-type": contentType }),
|
|
47
|
-
status,
|
|
48
|
-
statusText,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
if (!ok) {
|
|
52
|
-
response.clone = () => ({
|
|
53
|
-
json: () => Promise.resolve(data),
|
|
54
|
-
status,
|
|
55
|
-
statusText,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return response;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const makeTextResponse = (
|
|
63
|
-
text: string,
|
|
64
|
-
{
|
|
65
|
-
ok = true,
|
|
66
|
-
status = 200,
|
|
67
|
-
statusText = "OK",
|
|
68
|
-
contentType = "text/plain",
|
|
69
|
-
}: {
|
|
70
|
-
ok?: boolean;
|
|
71
|
-
status?: number;
|
|
72
|
-
statusText?: string;
|
|
73
|
-
contentType?: string;
|
|
74
|
-
} = {},
|
|
75
|
-
) => {
|
|
76
|
-
const response: any = {
|
|
77
|
-
ok,
|
|
78
|
-
text: () => Promise.resolve(text),
|
|
79
|
-
headers: new Headers({ "content-type": contentType }),
|
|
80
|
-
status,
|
|
81
|
-
statusText,
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
if (!ok) {
|
|
85
|
-
response.clone = () => ({
|
|
86
|
-
text: () => Promise.resolve(text),
|
|
87
|
-
status,
|
|
88
|
-
statusText,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return response;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const mockFetchResolved = (response: any) => {
|
|
96
|
-
global.fetch = vi.fn().mockResolvedValue(response);
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
it("uses default requestOptions when no additional options are provided", async () => {
|
|
100
|
-
mockFetchResolved(makeJsonResponse({}));
|
|
101
|
-
|
|
102
|
-
await authFetch(`${domain}/${path}?${queryParams}`, {});
|
|
103
|
-
|
|
104
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
105
|
-
`${domain}/${path}?${queryParams}`,
|
|
106
|
-
expect.objectContaining({
|
|
107
|
-
method: "GET",
|
|
108
|
-
headers: expect.any(Headers),
|
|
109
|
-
}),
|
|
110
|
-
);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("merges additional options into requestOptions", async () => {
|
|
114
|
-
mockFetchResolved(makeJsonResponse({}));
|
|
115
|
-
|
|
116
|
-
const additionalOptions = {
|
|
117
|
-
method: "POST",
|
|
118
|
-
body: JSON.stringify({ data: "test" }),
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
await authFetch(`${domain}/${path}?${queryParams}`, additionalOptions);
|
|
122
|
-
|
|
123
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
124
|
-
`${domain}/${path}?${queryParams}`,
|
|
125
|
-
expect.objectContaining({
|
|
126
|
-
method: "POST",
|
|
127
|
-
headers: expect.any(Headers),
|
|
128
|
-
body: '{"data":"test"}',
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("includes the Authorization header with the token", async () => {
|
|
134
|
-
mockFetchResolved(makeJsonResponse({}));
|
|
135
|
-
|
|
136
|
-
await authFetch(`${domain}/${path}`, {});
|
|
137
|
-
|
|
138
|
-
const callArgs = (global.fetch as any).mock.calls[0];
|
|
139
|
-
const headers = callArgs[1].headers as Headers;
|
|
140
|
-
|
|
141
|
-
expect(headers.get("Authorization")).toBe("Bearer test-token");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("logs success when response is ok with JSON content", async () => {
|
|
145
|
-
mockFetchResolved(makeJsonResponse({ data: "success" }));
|
|
146
|
-
|
|
147
|
-
await authFetch(`${domain}/${path}?${queryParams}`);
|
|
148
|
-
|
|
149
|
-
const logger = logger_module.logger;
|
|
150
|
-
expect(logger.info).toHaveBeenCalledWith(
|
|
151
|
-
expect.stringContaining("API request succeeded"),
|
|
152
|
-
expect.objectContaining({
|
|
153
|
-
path: `/${path}`,
|
|
154
|
-
status: 200,
|
|
155
|
-
}),
|
|
156
|
-
);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("throws error when response is not ok with JSON error body", async () => {
|
|
160
|
-
mockFetchResolved(
|
|
161
|
-
makeJsonResponse(
|
|
162
|
-
{
|
|
163
|
-
context: {
|
|
164
|
-
errors: [{ msg: "Invalid request" }, { msg: "Missing field" }],
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
{ ok: false, status: 400, statusText: "Bad Request" },
|
|
168
|
-
),
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow("Invalid request | Missing field");
|
|
172
|
-
|
|
173
|
-
const logger = logger_module.logger;
|
|
174
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
175
|
-
expect.stringContaining("API request failed"),
|
|
176
|
-
expect.any(Object),
|
|
177
|
-
);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("throws error when response is not ok with text error body", async () => {
|
|
181
|
-
mockFetchResolved(
|
|
182
|
-
makeTextResponse("Error message", {
|
|
183
|
-
ok: false,
|
|
184
|
-
status: 500,
|
|
185
|
-
statusText: "Internal Server Error",
|
|
186
|
-
}),
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow("Error message");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("logs error with session data when request fails", async () => {
|
|
193
|
-
mockFetchResolved(makeJsonResponse({}, { ok: false, status: 401, statusText: "Unauthorized" }));
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
await authFetch(`${domain}/${path}`);
|
|
197
|
-
} catch {
|
|
198
|
-
// Expected error
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const logger = logger_module.logger;
|
|
202
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
203
|
-
expect.any(String),
|
|
204
|
-
expect.objectContaining({
|
|
205
|
-
status: 401,
|
|
206
|
-
user: expect.any(Object),
|
|
207
|
-
}),
|
|
208
|
-
);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("logs error with anonymous user when session has no user", async () => {
|
|
212
|
-
vi.mocked(auth_module.auth).mockResolvedValue({} as any);
|
|
213
|
-
|
|
214
|
-
mockFetchResolved(makeJsonResponse({}, { ok: false, status: 401, statusText: "Unauthorized" }));
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
await authFetch(`${domain}/${path}`);
|
|
218
|
-
} catch {
|
|
219
|
-
// Expected error
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const logger = logger_module.logger;
|
|
223
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
224
|
-
expect.any(String),
|
|
225
|
-
expect.objectContaining({
|
|
226
|
-
user: expect.objectContaining({
|
|
227
|
-
name: "Anonymous",
|
|
228
|
-
email: "anonymous@shell.com",
|
|
229
|
-
}),
|
|
230
|
-
}),
|
|
231
|
-
);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("returns response object on successful fetch", async () => {
|
|
235
|
-
const mockResponse = makeJsonResponse({ data: "test" });
|
|
236
|
-
|
|
237
|
-
mockFetchResolved(mockResponse);
|
|
238
|
-
|
|
239
|
-
const response = await authFetch(`${domain}/${path}`);
|
|
240
|
-
|
|
241
|
-
expect(response).toBe(mockResponse);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it("sets Content-Type header to application/json", async () => {
|
|
245
|
-
mockFetchResolved(makeJsonResponse({}));
|
|
246
|
-
|
|
247
|
-
await authFetch(`${domain}/${path}`, {});
|
|
248
|
-
|
|
249
|
-
const callArgs = (global.fetch as any).mock.calls[0];
|
|
250
|
-
const headers = callArgs[1].headers as Headers;
|
|
251
|
-
|
|
252
|
-
expect(headers.get("Content-Type")).toBe("application/json");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("does not set Authorization header when session has no access_token", async () => {
|
|
256
|
-
vi.mocked(auth_module.auth).mockResolvedValueOnce({
|
|
257
|
-
user: { name: "Test User", email: "test@example.com" },
|
|
258
|
-
} as any);
|
|
259
|
-
|
|
260
|
-
mockFetchResolved(makeJsonResponse({}));
|
|
261
|
-
|
|
262
|
-
await authFetch(`${domain}/${path}`, {});
|
|
263
|
-
|
|
264
|
-
const callArgs = (global.fetch as any).mock.calls[0];
|
|
265
|
-
const headers = callArgs[1].headers as Headers;
|
|
266
|
-
|
|
267
|
-
expect(headers.get("Authorization")).toBeNull();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("handles error response with no error context", async () => {
|
|
271
|
-
mockFetchResolved(
|
|
272
|
-
makeJsonResponse(
|
|
273
|
-
{ someOtherField: "value" },
|
|
274
|
-
{ ok: false, status: 400, statusText: "Bad Request" },
|
|
275
|
-
),
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(
|
|
279
|
-
"Failed to read error response text.",
|
|
280
|
-
);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("handles error response with empty error array", async () => {
|
|
284
|
-
mockFetchResolved(
|
|
285
|
-
makeJsonResponse(
|
|
286
|
-
{ context: { errors: [] } },
|
|
287
|
-
{ ok: false, status: 400, statusText: "Bad Request" },
|
|
288
|
-
),
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(
|
|
292
|
-
"Failed to read error response text.",
|
|
293
|
-
);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("handles error response with error messages from error array", async () => {
|
|
297
|
-
mockFetchResolved(
|
|
298
|
-
makeJsonResponse(
|
|
299
|
-
{
|
|
300
|
-
context: {
|
|
301
|
-
errors: [{ msg: "Error 1" }, { msg: "Error 2" }, { other: "field" }],
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
{ ok: false, status: 400, statusText: "Bad Request" },
|
|
305
|
-
),
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(" | Error 1 | Error 2");
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it("handles error response with non-JSON content type", async () => {
|
|
312
|
-
mockFetchResolved(
|
|
313
|
-
makeTextResponse("Text error response", {
|
|
314
|
-
ok: false,
|
|
315
|
-
status: 400,
|
|
316
|
-
statusText: "Bad Request",
|
|
317
|
-
}),
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow("Text error response");
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("handles error response with empty text response", async () => {
|
|
324
|
-
mockFetchResolved(
|
|
325
|
-
makeTextResponse("", {
|
|
326
|
-
ok: false,
|
|
327
|
-
status: 400,
|
|
328
|
-
statusText: "Bad Request",
|
|
329
|
-
}),
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(
|
|
333
|
-
"Failed to read error response text.",
|
|
334
|
-
);
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it("returns response when throwOnError is false and response is not ok", async () => {
|
|
338
|
-
const mockResponse = makeJsonResponse(
|
|
339
|
-
{
|
|
340
|
-
context: {
|
|
341
|
-
errors: [{ msg: "Error message" }],
|
|
342
|
-
},
|
|
343
|
-
},
|
|
344
|
-
{ ok: false, status: 400, statusText: "Bad Request" },
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
mockFetchResolved(mockResponse);
|
|
348
|
-
|
|
349
|
-
const response = await authFetch(`${domain}/${path}`, {}, false);
|
|
350
|
-
|
|
351
|
-
expect(response).toBe(mockResponse);
|
|
352
|
-
expect(response.ok).toBe(false);
|
|
353
|
-
// Verify the response body is still readable via clone
|
|
354
|
-
const clonedBody = await response.clone().json();
|
|
355
|
-
expect(clonedBody).toEqual({ context: { errors: [{ msg: "Error message" }] } });
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("logs error but does not throw when throwOnError is false", async () => {
|
|
359
|
-
const mockResponse = makeJsonResponse(
|
|
360
|
-
{
|
|
361
|
-
context: {
|
|
362
|
-
errors: [{ msg: "Test error" }],
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
{ ok: false, status: 400, statusText: "Bad Request" },
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
mockFetchResolved(mockResponse);
|
|
369
|
-
|
|
370
|
-
const response = await authFetch(`${domain}/${path}`, {}, false);
|
|
371
|
-
|
|
372
|
-
expect(response.ok).toBe(false);
|
|
373
|
-
|
|
374
|
-
const logger = logger_module.logger;
|
|
375
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
376
|
-
expect.stringContaining("API request failed"),
|
|
377
|
-
expect.any(Object),
|
|
378
|
-
);
|
|
379
|
-
// Verify the response body is still readable via clone
|
|
380
|
-
const clonedBody = await response.clone().json();
|
|
381
|
-
expect(clonedBody).toEqual({ context: { errors: [{ msg: "Test error" }] } });
|
|
382
|
-
});
|
|
383
|
-
});
|