Skip to content

Commit b701620

Browse files
committed
feat: add cross-origin reporting for telemetry in the dashboard (coder#13612)
* feat: add cross-origin reporting for telemetry in the dashboard * Respect the telemetry flag * Fix embedded metadata * Fix compilation error * Fix linting (cherry picked from commit 0793a4b)
1 parent 0703fc6 commit b701620

File tree

15 files changed

+131
-5
lines changed

15 files changed

+131
-5
lines changed

coderd/apidoc/docs.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ func New(options *Options) *API {
450450
WorkspaceProxy: false,
451451
UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(),
452452
DeploymentID: api.DeploymentID,
453+
Telemetry: api.Telemetry.Enabled(),
453454
}
454455
api.SiteHandler = site.New(&site.Options{
455456
BinFS: binFS,

coderd/telemetry/telemetry.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ type Reporter interface {
100100
// database. For example, if a new user is added, a snapshot can
101101
// contain just that user entry.
102102
Report(snapshot *Snapshot)
103+
Enabled() bool
103104
Close()
104105
}
105106

@@ -116,6 +117,10 @@ type remoteReporter struct {
116117
shutdownAt *time.Time
117118
}
118119

120+
func (*remoteReporter) Enabled() bool {
121+
return true
122+
}
123+
119124
func (r *remoteReporter) Report(snapshot *Snapshot) {
120125
go r.reportSync(snapshot)
121126
}
@@ -992,4 +997,5 @@ type Experiment struct {
992997
type noopReporter struct{}
993998

994999
func (*noopReporter) Report(_ *Snapshot) {}
1000+
func (*noopReporter) Enabled() bool { return false }
9951001
func (*noopReporter) Close() {}

codersdk/deployment.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2162,11 +2162,12 @@ type BuildInfoResponse struct {
21622162
ExternalURL string `json:"external_url"`
21632163
// Version returns the semantic version of the build.
21642164
Version string `json:"version"`
2165-
21662165
// DashboardURL is the URL to hit the deployment's dashboard.
21672166
// For external workspace proxies, this is the coderd they are connected
21682167
// to.
21692168
DashboardURL string `json:"dashboard_url"`
2169+
// Telemetry is a boolean that indicates whether telemetry is enabled.
2170+
Telemetry bool `json:"telemetry"`
21702171

21712172
WorkspaceProxy bool `json:"workspace_proxy"`
21722173

docs/api/general.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/schemas.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ func TestDialCoordinator(t *testing.T) {
216216
Node: &proto.Node{
217217
Id: 55,
218218
AsOf: timestamppb.New(time.Unix(1689653252, 0)),
219-
Key: peerNodeKey[:],
219+
Key: peerNodeKey,
220220
Disco: string(peerDiscoKey),
221221
PreferredDerp: 0,
222222
DerpLatency: map[string]float64{

site/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ global.scrollTo = jest.fn();
4141

4242
window.HTMLElement.prototype.scrollIntoView = jest.fn();
4343
window.open = jest.fn();
44+
navigator.sendBeacon = jest.fn();
4445

4546
// Polyfill the getRandomValues that is used on utils/random.ts
4647
Object.defineProperty(global.self, "crypto", {

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/pages/LoginPage/LoginPage.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider";
88
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
99
import { getApplicationName } from "utils/appearance";
1010
import { retrieveRedirect } from "utils/redirect";
11+
import { sendDeploymentEvent } from "utils/telemetry";
1112
import { LoginPageView } from "./LoginPageView";
1213

1314
export const LoginPage: FC = () => {
@@ -19,6 +20,7 @@ export const LoginPage: FC = () => {
1920
signIn,
2021
isSigningIn,
2122
signInError,
23+
user,
2224
} = useAuthContext();
2325
const authMethodsQuery = useQuery(authMethods());
2426
const redirectTo = retrieveRedirect(location.search);
@@ -40,6 +42,15 @@ export const LoginPage: FC = () => {
4042
}, [isSignedIn, buildInfoQuery.data, user?.id]);
4143

4244
if (isSignedIn) {
45+
if (buildInfoQuery.data) {
46+
// This uses `navigator.sendBeacon`, so window.href
47+
// will not stop the request from being sent!
48+
sendDeploymentEvent(buildInfoQuery.data, {
49+
type: "deployment_login",
50+
user_id: user?.id,
51+
});
52+
}
53+
4354
// If the redirect is going to a workspace application, and we
4455
// are missing authentication, then we need to change the href location
4556
// to trigger a HTTP request. This allows the BE to generate the auth
@@ -85,6 +96,15 @@ export const LoginPage: FC = () => {
8596
isSigningIn={isSigningIn}
8697
onSignIn={async ({ email, password }) => {
8798
await signIn(email, password);
99+
if (buildInfoQuery.data) {
100+
// This uses `navigator.sendBeacon`, so navigating away
101+
// will not prevent it!
102+
sendDeploymentEvent(buildInfoQuery.data, {
103+
type: "deployment_login",
104+
user_id: user?.id,
105+
});
106+
}
107+
88108
navigate("/");
89109
}}
90110
/>

site/src/pages/SetupPage/SetupPage.test.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
33
import { HttpResponse, http } from "msw";
44
import { createMemoryRouter } from "react-router-dom";
55
import type { Response, User } from "api/typesGenerated";
6-
import { MockUser } from "testHelpers/entities";
6+
import { MockBuildInfo, MockUser } from "testHelpers/entities";
77
import {
88
renderWithRouter,
99
waitForLoaderToBeRemoved,
@@ -99,4 +99,42 @@ describe("Setup Page", () => {
9999
await fillForm();
100100
await waitFor(() => screen.findByText("Templates"));
101101
});
102+
it("calls sendBeacon with telemetry", async () => {
103+
const sendBeacon = jest.fn();
104+
Object.defineProperty(window.navigator, "sendBeacon", {
105+
value: sendBeacon,
106+
});
107+
renderWithRouter(
108+
createMemoryRouter(
109+
[
110+
{
111+
path: "/setup",
112+
element: <SetupPage />,
113+
},
114+
{
115+
path: "/templates",
116+
element: <h1>Templates</h1>,
117+
},
118+
],
119+
{ initialEntries: ["/setup"] },
120+
),
121+
);
122+
await waitForLoaderToBeRemoved();
123+
await waitFor(() => {
124+
expect(navigator.sendBeacon).toBeCalledWith(
125+
"https://coder.com/api/track-deployment",
126+
new Blob(
127+
[
128+
JSON.stringify({
129+
type: "deployment_setup",
130+
deployment_id: MockBuildInfo.deployment_id,
131+
}),
132+
],
133+
{
134+
type: "application/json",
135+
},
136+
),
137+
);
138+
});
139+
});
102140
});

site/src/pages/SetupPage/SetupPage.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import type { FC } from "react";
1+
import { useEffect, type FC } from "react";
22
import { Helmet } from "react-helmet-async";
3-
import { useMutation } from "react-query";
3+
import { useMutation, useQuery } from "react-query";
44
import { Navigate, useNavigate } from "react-router-dom";
5+
import { buildInfo } from "api/queries/buildInfo";
56
import { createFirstUser } from "api/queries/users";
67
import { Loader } from "components/Loader/Loader";
78
import { useAuthContext } from "contexts/auth/AuthProvider";
9+
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
810
import { pageTitle } from "utils/page";
11+
import { sendDeploymentEvent } from "utils/telemetry";
912
import { SetupPageView } from "./SetupPageView";
1013

1114
export const SetupPage: FC = () => {
@@ -18,7 +21,17 @@ export const SetupPage: FC = () => {
1821
} = useAuthContext();
1922
const createFirstUserMutation = useMutation(createFirstUser());
2023
const setupIsComplete = !isConfiguringTheFirstUser;
24+
const { metadata } = useEmbeddedMetadata();
25+
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
2126
const navigate = useNavigate();
27+
useEffect(() => {
28+
if (!buildInfoQuery.data) {
29+
return;
30+
}
31+
sendDeploymentEvent(buildInfoQuery.data, {
32+
type: "deployment_setup",
33+
});
34+
}, [buildInfoQuery.data]);
2235

2336
if (isLoading) {
2437
return <Loader fullscreen />;

site/src/testHelpers/entities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = {
202202
workspace_proxy: false,
203203
upgrade_message: "My custom upgrade message",
204204
deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8",
205+
telemetry: true,
205206
};
206207

207208
export const MockSupportLinks: TypesGen.LinkConfig[] = [

site/src/utils/telemetry.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { BuildInfoResponse } from "api/typesGenerated";
2+
3+
// sendDeploymentEvent sends a CORs payload to coder.com
4+
// to track a deployment event.
5+
export const sendDeploymentEvent = (
6+
buildInfo: BuildInfoResponse,
7+
payload: {
8+
type: "deployment_setup" | "deployment_login";
9+
user_id?: string;
10+
},
11+
) => {
12+
if (typeof navigator === "undefined" || !navigator.sendBeacon) {
13+
// It's fine if we don't report this, it's not required!
14+
return;
15+
}
16+
if (!buildInfo.telemetry) {
17+
return;
18+
}
19+
navigator.sendBeacon(
20+
"https://coder.com/api/track-deployment",
21+
new Blob(
22+
[
23+
JSON.stringify({
24+
...payload,
25+
deployment_id: buildInfo.deployment_id,
26+
}),
27+
],
28+
{
29+
type: "application/json",
30+
},
31+
),
32+
);
33+
};

0 commit comments

Comments
 (0)