Skip to content

feat: add cross-origin reporting for telemetry in the dashboard #13612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ func New(options *Options) *API {
WorkspaceProxy: false,
UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(),
DeploymentID: api.DeploymentID,
Telemetry: api.Telemetry.Enabled(),
}
api.SiteHandler = site.New(&site.Options{
BinFS: binFS,
Expand Down
6 changes: 6 additions & 0 deletions coderd/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type Reporter interface {
// database. For example, if a new user is added, a snapshot can
// contain just that user entry.
Report(snapshot *Snapshot)
Enabled() bool
Close()
}

Expand All @@ -109,6 +110,10 @@ type remoteReporter struct {
shutdownAt *time.Time
}

func (*remoteReporter) Enabled() bool {
return true
}

func (r *remoteReporter) Report(snapshot *Snapshot) {
go r.reportSync(snapshot)
}
Expand Down Expand Up @@ -948,4 +953,5 @@ type ExternalProvisioner struct {
type noopReporter struct{}

func (*noopReporter) Report(_ *Snapshot) {}
func (*noopReporter) Enabled() bool { return false }
func (*noopReporter) Close() {}
3 changes: 2 additions & 1 deletion codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -2173,11 +2173,12 @@ type BuildInfoResponse struct {
ExternalURL string `json:"external_url"`
// Version returns the semantic version of the build.
Version string `json:"version"`

// DashboardURL is the URL to hit the deployment's dashboard.
// For external workspace proxies, this is the coderd they are connected
// to.
DashboardURL string `json:"dashboard_url"`
// Telemetry is a boolean that indicates whether telemetry is enabled.
Telemetry bool `json:"telemetry"`

WorkspaceProxy bool `json:"workspace_proxy"`

Expand Down
1 change: 1 addition & 0 deletions docs/api/general.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func TestDialCoordinator(t *testing.T) {
Node: &proto.Node{
Id: 55,
AsOf: timestamppb.New(time.Unix(1689653252, 0)),
Key: peerNodeKey[:],
Key: peerNodeKey,
Disco: string(peerDiscoKey),
PreferredDerp: 0,
DerpLatency: map[string]float64{
Expand Down
1 change: 1 addition & 0 deletions site/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ global.scrollTo = jest.fn();

window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.open = jest.fn();
navigator.sendBeacon = jest.fn();

// Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", {
Expand Down
1 change: 1 addition & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions site/src/pages/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { getApplicationName } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect";
import { sendDeploymentEvent } from "utils/telemetry";
import { LoginPageView } from "./LoginPageView";

export const LoginPage: FC = () => {
Expand All @@ -19,6 +20,7 @@ export const LoginPage: FC = () => {
signIn,
isSigningIn,
signInError,
user,
} = useAuthContext();
const authMethodsQuery = useQuery(authMethods());
const redirectTo = retrieveRedirect(location.search);
Expand All @@ -29,6 +31,15 @@ export const LoginPage: FC = () => {
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));

if (isSignedIn) {
if (buildInfoQuery.data) {
// This uses `navigator.sendBeacon`, so window.href
// will not stop the request from being sent!
sendDeploymentEvent(buildInfoQuery.data, {
type: "deployment_login",
user_id: user?.id,
});
}

// If the redirect is going to a workspace application, and we
// are missing authentication, then we need to change the href location
// to trigger a HTTP request. This allows the BE to generate the auth
Expand Down Expand Up @@ -74,6 +85,15 @@ export const LoginPage: FC = () => {
isSigningIn={isSigningIn}
onSignIn={async ({ email, password }) => {
await signIn(email, password);
if (buildInfoQuery.data) {
// This uses `navigator.sendBeacon`, so navigating away
// will not prevent it!
sendDeploymentEvent(buildInfoQuery.data, {
type: "deployment_login",
user_id: user?.id,
});
}

navigate("/");
}}
/>
Expand Down
40 changes: 39 additions & 1 deletion site/src/pages/SetupPage/SetupPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import { createMemoryRouter } from "react-router-dom";
import type { Response, User } from "api/typesGenerated";
import { MockUser } from "testHelpers/entities";
import { MockBuildInfo, MockUser } from "testHelpers/entities";
import {
renderWithRouter,
waitForLoaderToBeRemoved,
Expand Down Expand Up @@ -99,4 +99,42 @@ describe("Setup Page", () => {
await fillForm();
await waitFor(() => screen.findByText("Templates"));
});
it("calls sendBeacon with telemetry", async () => {
const sendBeacon = jest.fn();
Object.defineProperty(window.navigator, "sendBeacon", {
value: sendBeacon,
});
renderWithRouter(
createMemoryRouter(
[
{
path: "/setup",
element: <SetupPage />,
},
{
path: "/templates",
element: <h1>Templates</h1>,
},
],
{ initialEntries: ["/setup"] },
),
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(navigator.sendBeacon).toBeCalledWith(
"https://coder.com/api/track-deployment",
new Blob(
[
JSON.stringify({
type: "deployment_setup",
deployment_id: MockBuildInfo.deployment_id,
}),
],
{
type: "application/json",
},
),
);
});
});
});
17 changes: 15 additions & 2 deletions site/src/pages/SetupPage/SetupPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { FC } from "react";
import { useEffect, type FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation } from "react-query";
import { useMutation, useQuery } from "react-query";
import { Navigate, useNavigate } from "react-router-dom";
import { buildInfo } from "api/queries/buildInfo";
import { createFirstUser } from "api/queries/users";
import { Loader } from "components/Loader/Loader";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { pageTitle } from "utils/page";
import { sendDeploymentEvent } from "utils/telemetry";
import { SetupPageView } from "./SetupPageView";

export const SetupPage: FC = () => {
Expand All @@ -18,7 +21,17 @@ export const SetupPage: FC = () => {
} = useAuthContext();
const createFirstUserMutation = useMutation(createFirstUser());
const setupIsComplete = !isConfiguringTheFirstUser;
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const navigate = useNavigate();
useEffect(() => {
if (!buildInfoQuery.data) {
return;
}
sendDeploymentEvent(buildInfoQuery.data, {
type: "deployment_setup",
});
}, [buildInfoQuery.data]);

if (isLoading) {
return <Loader fullscreen />;
Expand Down
1 change: 1 addition & 0 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = {
workspace_proxy: false,
upgrade_message: "My custom upgrade message",
deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8",
telemetry: true,
};

export const MockSupportLinks: TypesGen.LinkConfig[] = [
Expand Down
33 changes: 33 additions & 0 deletions site/src/utils/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { BuildInfoResponse } from "api/typesGenerated";

// sendDeploymentEvent sends a CORs payload to coder.com
// to track a deployment event.
export const sendDeploymentEvent = (
buildInfo: BuildInfoResponse,
payload: {
type: "deployment_setup" | "deployment_login";
user_id?: string;
},
) => {
if (typeof navigator === "undefined" || !navigator.sendBeacon) {
// It's fine if we don't report this, it's not required!
return;
}
if (!buildInfo.telemetry) {
return;
}
navigator.sendBeacon(
"https://coder.com/api/track-deployment",
new Blob(
[
JSON.stringify({
...payload,
deployment_id: buildInfo.deployment_id,
}),
],
{
type: "application/json",
},
),
);
};
Loading