From df9ecd70864a563f99886f67ad507d2fda00a5c1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 17:40:28 +0000 Subject: [PATCH 1/5] feat: add cross-origin reporting for telemetry in the dashboard --- site/jest.setup.ts | 1 + site/src/pages/LoginPage/LoginPage.tsx | 19 ++++++++++++ site/src/pages/SetupPage/SetupPage.test.tsx | 32 ++++++++++++++++++++- site/src/pages/SetupPage/SetupPage.tsx | 25 ++++++++++++++-- site/src/utils/telemetry.ts | 18 ++++++++++++ 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 site/src/utils/telemetry.ts diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 6282295870681..40bb92fa44965 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -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", { diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index f05c0b40d981f..3e44055f9c2f6 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -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 = () => { @@ -19,6 +20,7 @@ export const LoginPage: FC = () => { signIn, isSigningIn, signInError, + user, } = useAuthContext(); const authMethodsQuery = useQuery(authMethods()); const redirectTo = retrieveRedirect(location.search); @@ -29,6 +31,16 @@ export const LoginPage: FC = () => { const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); if (isSignedIn) { + // This uses `navigator.sendBeacon`, so window.href + // will not stop the request from being sent! + sendDeploymentEvent({ + type: "deployment_login", + // This should work most of the time because of embedded + // metadata and the user being logged in. + deployment_id: buildInfoQuery.data?.deployment_id || "", + 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 @@ -74,6 +86,13 @@ export const LoginPage: FC = () => { isSigningIn={isSigningIn} onSignIn={async ({ email, password }) => { await signIn(email, password); + // This uses `navigator.sendBeacon`, so navigating away + // will not prevent it! + sendDeploymentEvent({ + type: "deployment_login", + deployment_id: buildInfoQuery.data?.deployment_id || "", + user_id: user?.id, + }) navigate("/"); }} /> diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index 2f558316d95cc..72288d164dc2c 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -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, @@ -99,4 +99,34 @@ 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: , + }, + { + path: "/templates", + element:

Templates

, + }, + ], + { 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", + })) + }) + }) }); diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index fc5d0cf35f957..c12b8c4431d08 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -1,14 +1,19 @@ -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 = () => { +export const SetupPage: FC<{ + telemetryURL?: string; +}> = ({ telemetryURL }) => { const { isLoading, signIn, @@ -18,7 +23,21 @@ 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( + { + type: "deployment_setup", + deployment_id: buildInfoQuery.data.deployment_id, + }, + telemetryURL, + ); + }, [buildInfoQuery.data, telemetryURL]); if (isLoading) { return ; diff --git a/site/src/utils/telemetry.ts b/site/src/utils/telemetry.ts new file mode 100644 index 0000000000000..6918392b48546 --- /dev/null +++ b/site/src/utils/telemetry.ts @@ -0,0 +1,18 @@ +// sendDeploymentEvent sends a CORs payload to coder.com +// to track a deployment event. +export const sendDeploymentEvent = (payload: { + type: "deployment_setup" | "deployment_login"; + deployment_id: string; + user_id?: string; +}) => { + if (typeof navigator === "undefined" || !navigator.sendBeacon) { + // It's fine if we don't report this, it's not required! + return; + } + navigator.sendBeacon( + "https://coder.com/api/track-deployment", + new Blob([JSON.stringify(payload)], { + type: "application/json", + }), + ); +}; From 9b5c997f48fa1d77d13d2a13e244ecc11be9628d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 18:11:36 +0000 Subject: [PATCH 2/5] Respect the telemetry flag --- coderd/apidoc/docs.go | 4 +++ coderd/apidoc/swagger.json | 4 +++ coderd/coderd.go | 1 + coderd/telemetry/telemetry.go | 6 ++++ codersdk/deployment.go | 3 +- docs/api/general.md | 1 + docs/api/schemas.md | 2 ++ .../wsproxy/wsproxysdk/wsproxysdk_test.go | 2 +- site/src/api/typesGenerated.ts | 1 + site/src/hooks/useEmbeddedMetadata.ts | 2 ++ site/src/pages/LoginPage/LoginPage.tsx | 33 ++++++++++--------- site/src/pages/SetupPage/SetupPage.test.tsx | 24 +++++++++----- site/src/pages/SetupPage/SetupPage.tsx | 16 +++------ site/src/testHelpers/entities.ts | 1 + site/src/utils/telemetry.ts | 31 ++++++++++++----- 15 files changed, 86 insertions(+), 45 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 31330cd175222..88d08e868690c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8351,6 +8351,10 @@ const docTemplate = `{ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "telemetry": { + "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", + "type": "boolean" + }, "upgrade_message": { "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 254eaa54c46dd..b6e527d0580d7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7430,6 +7430,10 @@ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "telemetry": { + "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", + "type": "boolean" + }, "upgrade_message": { "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", "type": "string" diff --git a/coderd/coderd.go b/coderd/coderd.go index cc2de344a2cee..6de169cce71b7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 1b9489db3af8f..e937ba9ba11f8 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -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() } @@ -109,6 +110,10 @@ type remoteReporter struct { shutdownAt *time.Time } +func (r *remoteReporter) Enabled() bool { + return true +} + func (r *remoteReporter) Report(snapshot *Snapshot) { go r.reportSync(snapshot) } @@ -948,4 +953,5 @@ type ExternalProvisioner struct { type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} +func (*noopReporter) Enabled() bool { return false } func (*noopReporter) Close() {} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ff35d67bacbb4..7b13d083a4435 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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"` diff --git a/docs/api/general.md b/docs/api/general.md index a92742ce0a707..620e3b238d7b3 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -57,6 +57,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "dashboard_url": "string", "deployment_id": "string", "external_url": "string", + "telemetry": true, "upgrade_message": "string", "version": "string", "workspace_proxy": true diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a5c333b3d0bd6..c2ee20e288d42 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -865,6 +865,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "dashboard_url": "string", "deployment_id": "string", "external_url": "string", + "telemetry": true, "upgrade_message": "string", "version": "string", "workspace_proxy": true @@ -879,6 +880,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | | `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | | `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | +| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | | `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | | `version` | string | false | | Version returns the semantic version of the build. | | `workspace_proxy` | boolean | false | | | diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go index 870d06b71da6d..c94b712cc9872 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go @@ -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{ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0d9147c912e9e..052b2a6872b04 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -171,6 +171,7 @@ export interface BuildInfoResponse { readonly external_url: string; readonly version: string; readonly dashboard_url: string; + readonly telemetry: boolean; readonly workspace_proxy: boolean; readonly agent_api_version: string; readonly upgrade_message: string; diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts index 61529fe70fa52..37651db959638 100644 --- a/site/src/hooks/useEmbeddedMetadata.ts +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -22,6 +22,7 @@ export const DEFAULT_METADATA_KEY = "property"; * attributes */ type AvailableMetadata = Readonly<{ + telemetry: boolean; user: User; experiments: Experiments; appearance: AppearanceConfig; @@ -81,6 +82,7 @@ export class MetadataManager implements MetadataManagerApi { this.trackedMetadataNodes = new Map(); this.metadata = { + telemetry: this.registerValue("telemetry"), user: this.registerValue("user"), appearance: this.registerValue("appearance"), entitlements: this.registerValue("entitlements"), diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 3e44055f9c2f6..3fa2c5616be29 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -31,15 +31,14 @@ export const LoginPage: FC = () => { const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); if (isSignedIn) { - // This uses `navigator.sendBeacon`, so window.href - // will not stop the request from being sent! - sendDeploymentEvent({ - type: "deployment_login", - // This should work most of the time because of embedded - // metadata and the user being logged in. - deployment_id: buildInfoQuery.data?.deployment_id || "", - user_id: user?.id, - }) + 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 @@ -86,13 +85,15 @@ export const LoginPage: FC = () => { isSigningIn={isSigningIn} onSignIn={async ({ email, password }) => { await signIn(email, password); - // This uses `navigator.sendBeacon`, so navigating away - // will not prevent it! - sendDeploymentEvent({ - type: "deployment_login", - deployment_id: buildInfoQuery.data?.deployment_id || "", - user_id: user?.id, - }) + 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("/"); }} /> diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index 72288d164dc2c..fb22dcf4f303a 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -121,12 +121,20 @@ describe("Setup Page", () => { ); 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", - })) - }) - }) + 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", + }, + ), + ); + }); + }); }); diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index c12b8c4431d08..faeaa4fafbbd5 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -11,9 +11,7 @@ import { pageTitle } from "utils/page"; import { sendDeploymentEvent } from "utils/telemetry"; import { SetupPageView } from "./SetupPageView"; -export const SetupPage: FC<{ - telemetryURL?: string; -}> = ({ telemetryURL }) => { +export const SetupPage: FC = () => { const { isLoading, signIn, @@ -30,14 +28,10 @@ export const SetupPage: FC<{ if (!buildInfoQuery.data) { return; } - sendDeploymentEvent( - { - type: "deployment_setup", - deployment_id: buildInfoQuery.data.deployment_id, - }, - telemetryURL, - ); - }, [buildInfoQuery.data, telemetryURL]); + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_setup", + }); + }, [buildInfoQuery.data, metadata.telemetry.value]); if (isLoading) { return ; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 055093570c7ab..8ddb6bd76b635 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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[] = [ diff --git a/site/src/utils/telemetry.ts b/site/src/utils/telemetry.ts index 6918392b48546..3b6906690cd6a 100644 --- a/site/src/utils/telemetry.ts +++ b/site/src/utils/telemetry.ts @@ -1,18 +1,33 @@ +import type { BuildInfoResponse } from "api/typesGenerated"; + // sendDeploymentEvent sends a CORs payload to coder.com // to track a deployment event. -export const sendDeploymentEvent = (payload: { - type: "deployment_setup" | "deployment_login"; - deployment_id: string; - user_id?: string; -}) => { +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)], { - type: "application/json", - }), + new Blob( + [ + JSON.stringify({ + ...payload, + deployment_id: buildInfo.deployment_id, + }), + ], + { + type: "application/json", + }, + ), ); }; From b98df0d3dd611527ef05fa308cde0ceada1a733d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 18:14:56 +0000 Subject: [PATCH 3/5] Fix embedded metadata --- site/src/hooks/useEmbeddedMetadata.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts index 37651db959638..61529fe70fa52 100644 --- a/site/src/hooks/useEmbeddedMetadata.ts +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -22,7 +22,6 @@ export const DEFAULT_METADATA_KEY = "property"; * attributes */ type AvailableMetadata = Readonly<{ - telemetry: boolean; user: User; experiments: Experiments; appearance: AppearanceConfig; @@ -82,7 +81,6 @@ export class MetadataManager implements MetadataManagerApi { this.trackedMetadataNodes = new Map(); this.metadata = { - telemetry: this.registerValue("telemetry"), user: this.registerValue("user"), appearance: this.registerValue("appearance"), entitlements: this.registerValue("entitlements"), From fef57f6fa64789b8dffd8d1edf04294094570b70 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 18:18:46 +0000 Subject: [PATCH 4/5] Fix compilation error --- site/src/pages/SetupPage/SetupPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index faeaa4fafbbd5..20899157c3b30 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -31,7 +31,7 @@ export const SetupPage: FC = () => { sendDeploymentEvent(buildInfoQuery.data, { type: "deployment_setup", }); - }, [buildInfoQuery.data, metadata.telemetry.value]); + }, [buildInfoQuery.data]); if (isLoading) { return ; From 56affd7fed88c8fdcb33bf02f2fd086da7f81370 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 18:29:02 +0000 Subject: [PATCH 5/5] Fix linting --- coderd/telemetry/telemetry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index e937ba9ba11f8..9d16ba7922098 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -110,7 +110,7 @@ type remoteReporter struct { shutdownAt *time.Time } -func (r *remoteReporter) Enabled() bool { +func (*remoteReporter) Enabled() bool { return true }