mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add cross-origin reporting for telemetry in the dashboard (#13612)
* feat: add cross-origin reporting for telemetry in the dashboard * Respect the telemetry flag * Fix embedded metadata * Fix compilation error * Fix linting
This commit is contained in:
Generated
+4
@@ -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"
|
||||
|
||||
Generated
+4
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (*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() {}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
Generated
+2
@@ -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 | | |
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Generated
+1
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
@@ -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("/");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,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",
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user