feat: Add GA integration, support for GA4 (#4626)

* GA integration settings

* trackingId -> measurementId
Hook up script

* Public page GA tracking
Correct layout of settings

* Remove multiple codepaths for loading GA measurementID, add missing db index

* Remove unneccessary changes, tsc

* test
This commit is contained in:
Tom Moor
2023-01-01 15:29:08 +00:00
committed by GitHub
parent dc795604a4
commit 8e4270c321
29 changed files with 374 additions and 114 deletions

View File

@@ -330,7 +330,7 @@ export class Environment {
public RELEASE = this.toOptionalString(process.env.RELEASE);
/**
* A Google Analytics tracking ID, only v3 supported at this time.
* A Google Analytics tracking ID, supports only v3 properties.
*/
@Contains("UA-")
@IsOptional()

View File

@@ -0,0 +1,11 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addIndex("integrations", ["teamId", "type", "service"]);
},
async down (queryInterface, Sequelize) {
await queryInterface.removeIndex("integrations", ["teamId", "type", "service"]);
}
};

View File

@@ -7,7 +7,7 @@ import {
Scopes,
IsIn,
} from "sequelize-typescript";
import { IntegrationType } from "@shared/types";
import { IntegrationType, IntegrationService } from "@shared/types";
import type { IntegrationSettings } from "@shared/types";
import Collection from "./Collection";
import IntegrationAuthentication from "./IntegrationAuthentication";
@@ -16,13 +16,9 @@ import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
export enum IntegrationService {
Diagrams = "diagrams",
Slack = "slack",
}
export enum UserCreatableIntegrationService {
Diagrams = "diagrams",
GoogleAnalytics = "google-analytics",
}
@Scopes(() => ({
@@ -40,12 +36,12 @@ export enum UserCreatableIntegrationService {
@Fix
class Integration<T = unknown> extends IdModel {
@IsIn([Object.values(IntegrationType)])
@Column
type: string;
@Column(DataType.STRING)
type: IntegrationType;
@IsIn([Object.values(IntegrationService)])
@Column
service: string;
@Column(DataType.STRING)
service: IntegrationService;
@Column(DataType.JSONB)
settings: IntegrationSettings<T>;

View File

@@ -5,6 +5,7 @@ import {
BelongsTo,
Column,
} from "sequelize-typescript";
import { IntegrationService } from "@shared/types";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
@@ -17,8 +18,8 @@ import Fix from "./decorators/Fix";
@Table({ tableName: "authentications", modelName: "authentication" })
@Fix
class IntegrationAuthentication extends IdModel {
@Column
service: string;
@Column(DataType.STRING)
service: IntegrationService;
@Column(DataType.ARRAY(DataType.STRING))
scopes: string[];

View File

@@ -1,9 +1,13 @@
import { PublicEnv } from "@shared/types";
import { IntegrationType, PublicEnv } from "@shared/types";
import { Environment } from "@server/env";
import { Integration } from "@server/models";
// Note: This entire object is stringified in the HTML exposed to the client
// do not add anything here that should be a secret or password
export default function present(env: Environment): PublicEnv {
export default function present(
env: Environment,
analytics?: Integration<IntegrationType.Analytics> | null
): PublicEnv {
return {
URL: env.URL.replace(/\/$/, ""),
AWS_S3_UPLOAD_BUCKET_URL: process.env.AWS_S3_UPLOAD_BUCKET_URL || "",
@@ -26,5 +30,9 @@ export default function present(env: Environment): PublicEnv {
GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID,
RELEASE:
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
analytics: {
service: analytics?.service,
settings: analytics?.settings,
},
};
}

View File

@@ -1,6 +1,6 @@
import fetch from "fetch-with-proxy";
import { Op } from "sequelize";
import { IntegrationType } from "@shared/types";
import { IntegrationService, IntegrationType } from "@shared/types";
import env from "@server/env";
import { Document, Integration, Collection, Team } from "@server/models";
import { presentSlackAttachment } from "@server/presenters";
@@ -36,8 +36,8 @@ export default class SlackProcessor extends BaseProcessor {
const integration = (await Integration.findOne({
where: {
id: event.modelId,
service: "slack",
type: "post",
service: IntegrationService.Slack,
type: IntegrationType.Post,
},
include: [
{
@@ -98,8 +98,8 @@ export default class SlackProcessor extends BaseProcessor {
where: {
teamId: document.teamId,
collectionId: document.collectionId,
service: "slack",
type: "post",
service: IntegrationService.Slack,
type: IntegrationType.Post,
events: {
[Op.contains]: [
event.name === "revisions.create" ? "documents.update" : event.name,

View File

@@ -1,3 +1,4 @@
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { IntegrationAuthentication, SearchQuery } from "@server/models";
import { buildDocument, buildIntegration } from "@server/test/factories";
@@ -14,7 +15,7 @@ describe("#hooks.unfurl", () => {
it("should return documents", async () => {
const { user, document } = await seed();
await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: "",

View File

@@ -1,6 +1,7 @@
import crypto from "crypto";
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { AuthenticationError, InvalidRequestError } from "@server/errors";
import Logger from "@server/logging/Logger";
@@ -67,7 +68,7 @@ router.post("hooks.unfurl", async (ctx) => {
}
const auth = await IntegrationAuthentication.findOne({
where: {
service: "slack",
service: IntegrationService.Slack,
teamId: user.teamId,
},
});
@@ -240,7 +241,7 @@ router.post("hooks.slack", async (ctx) => {
if (!user) {
const auth = await IntegrationAuthentication.findOne({
where: {
service: "slack",
service: IntegrationService.Slack,
teamId: team.id,
},
});

View File

@@ -1,5 +1,6 @@
import Router from "koa-router";
import { has } from "lodash";
import { WhereOptions } from "sequelize";
import { IntegrationType } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { Event } from "@server/models";
@@ -21,17 +22,27 @@ const router = new Router();
router.post("integrations.list", auth(), pagination(), async (ctx) => {
let { direction } = ctx.request.body;
const { sort = "updatedAt" } = ctx.request.body;
const { user } = ctx.state;
const { type, sort = "updatedAt" } = ctx.request.body;
if (direction !== "ASC") {
direction = "DESC";
}
assertSort(sort, Integration);
const { user } = ctx.state;
let where: WhereOptions<Integration> = {
teamId: user.teamId,
};
if (type) {
assertIn(type, Object.values(IntegrationType));
where = {
...where,
type,
};
}
const integrations = await Integration.findAll({
where: {
teamId: user.teamId,
},
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,

View File

@@ -5,8 +5,10 @@ import { Context, Next } from "koa";
import { escape } from "lodash";
import { Sequelize } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { IntegrationType } from "@shared/types";
import documentLoader from "@server/commands/documentLoader";
import env from "@server/env";
import { Integration } from "@server/models";
import presentEnv from "@server/presenters/env";
import { getTeamFromContext } from "@server/utils/passport";
import prefetchTags from "@server/utils/prefetchTags";
@@ -54,7 +56,12 @@ const readIndexFile = async (ctx: Context): Promise<Buffer> => {
export const renderApp = async (
ctx: Context,
next: Next,
options: { title?: string; description?: string; canonical?: string } = {}
options: {
title?: string;
description?: string;
canonical?: string;
analytics?: Integration | null;
} = {}
) => {
const {
title = "Outline",
@@ -69,7 +76,7 @@ export const renderApp = async (
const { shareId } = ctx.params;
const page = await readIndexFile(ctx);
const environment = `
window.env = ${JSON.stringify(presentEnv(env))};
window.env = ${JSON.stringify(presentEnv(env, options.analytics))};
`;
ctx.body = page
.toString()
@@ -86,7 +93,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
// Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity
let share, document;
let share, document, analytics;
try {
const team = await getTeamFromContext(ctx);
@@ -105,6 +112,13 @@ export const renderShare = async (ctx: Context, next: Next) => {
}
document = result.document;
analytics = await Integration.findOne({
where: {
teamId: document.teamId,
type: IntegrationType.Analytics,
},
});
if (share && !ctx.userAgent.isBot) {
await share.update({
lastAccessedAt: new Date(),
@@ -123,6 +137,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
return renderApp(ctx, next, {
title: document?.title,
description: document?.getSummary(),
analytics,
canonical: share
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
: undefined,

View File

@@ -3,6 +3,7 @@ import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
@@ -173,15 +174,15 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const endpoint = `${env.URL}/auth/slack.commands`;
const data = await Slack.oauthAccess(String(code), endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "command",
service: IntegrationService.Slack,
type: IntegrationType.Command,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
@@ -239,7 +240,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const endpoint = `${env.URL}/auth/slack.post`;
const data = await Slack.oauthAccess(code as string, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
@@ -247,8 +248,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
});
await Integration.create({
service: "slack",
type: "post",
service: IntegrationService.Slack,
type: IntegrationType.Post,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,

View File

@@ -5,9 +5,12 @@ import Router from "koa-router";
import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent";
import { languages } from "@shared/i18n";
import { IntegrationType } from "@shared/types";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
import { Integration } from "@server/models";
import { opensearchResponse } from "@server/utils/opensearch";
import { getTeamFromContext } from "@server/utils/passport";
import { robotsResponse } from "@server/utils/robots";
import apexRedirect from "../middlewares/apexRedirect";
import { renderApp, renderShare } from "./app";
@@ -118,7 +121,21 @@ router.get("/s/:shareId/doc/:documentSlug", renderShare);
router.get("/s/:shareId/*", renderShare);
// catch all for application
router.get("*", renderApp);
router.get("*", async (ctx, next) => {
const team = await getTeamFromContext(ctx);
const analytics = team
? await Integration.findOne({
where: {
teamId: team.id,
type: IntegrationType.Analytics,
},
})
: undefined;
return renderApp(ctx, next, {
analytics,
});
});
// In order to report all possible performance metrics to Sentry this header
// must be provided when serving the application, see:

View File

@@ -23,6 +23,7 @@ const scriptSrc = [
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
"www.googletagmanager.com",
"cdn.zapier.com",
];

View File

@@ -4,6 +4,8 @@ import {
CollectionPermission,
FileOperationState,
FileOperationType,
IntegrationService,
IntegrationType,
} from "@shared/types";
import {
Share,
@@ -241,15 +243,15 @@ export async function buildIntegration(overrides: Partial<Integration> = {}) {
teamId: overrides.teamId,
});
const authentication = await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: "fake-access-token",
scopes: ["example", "scopes", "here"],
});
return Integration.create({
type: "post",
service: "slack",
service: IntegrationService.Slack,
type: IntegrationType.Post,
events: ["documents.update", "documents.publish"],
settings: {
serviceTeamId: "slack_team_id",

View File

@@ -89,7 +89,7 @@ export function assertUrl(
require_valid_protocol: true,
})
) {
throw ValidationError(message ?? `${String(value)} is an invalid url!`);
throw ValidationError(message ?? `${String(value)} is an invalid url`);
}
}
@@ -105,9 +105,7 @@ export function assertBoolean(
message?: string
): asserts value {
if (typeof value !== "boolean") {
throw ValidationError(
message ?? `${String(value)} is a ${typeof value}, not a boolean!`
);
throw ValidationError(message ?? `${String(value)} is not a boolean`);
}
}