Skip to content
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

Setup optional telemetry authorization #446

Merged
merged 4 commits into from
Nov 28, 2023
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
3 changes: 2 additions & 1 deletion src/apollo-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export interface OttofellerApolloServerProjectOptions
* @pjid ottofeller-apollo-server
*/
export class OttofellerApolloServerProject extends TypeScriptAppProject implements IWithTelemetryReportUrl {
readonly telemetryReportUrl?: string
readonly reportTargetUrl?: string
readonly reportTargetAuthHeaderName?: string

constructor(options: OttofellerApolloServerProjectOptions) {
super({
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export interface OttofellerCDKProjectOptions
*/
export class OttofellerCDKProject extends AwsCdkTypeScriptApp implements IWithTelemetryReportUrl {
public readonly initialReleaseVersion: string = '0.0.1'
readonly telemetryReportUrl?: string
readonly reportTargetUrl?: string
readonly reportTargetAuthHeaderName?: string

constructor(options: OttofellerCDKProjectOptions) {
super({
Expand Down
41 changes: 35 additions & 6 deletions src/common/telemetry/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,60 @@
import {NodeProject, NodeProjectOptions} from 'projen/lib/javascript'
import {synthSnapshot} from 'projen/lib/util/synth'
import * as YAML from 'yaml'
import {IWithTelemetryReportUrl, WithTelemetry, setupTelemetry} from '..'
import {reportTargetAuthToken} from '../collect-telemetry'

describe('setupTelemetry function', () => {
const telemetryWorkflowPath = '.github/workflows/telemetry.yml'

test('does nothing if telemetry options are not provided', () => {
const project = new TestProject()
setupTelemetry(project, {})
expect(project.telemetryReportUrl).toBeUndefined()
expect(project.reportTargetUrl).toBeUndefined()

const snapshot = synthSnapshot(project)
const telemetryWorkflow = snapshot['.github/workflows/telemetry.yml']
const telemetryWorkflow = snapshot[telemetryWorkflowPath]
expect(telemetryWorkflow).toBeUndefined()
})

test('sets up telemetry if requested in options', () => {
const project = new TestProject({})
setupTelemetry(project, {isTelemetryEnabled: true, telemetryUrl: 'http://localhost:3000/telemetry'})
expect(project.telemetryReportUrl).toBeDefined()
setupTelemetry(project, {isTelemetryEnabled: true, reportTargetUrl: 'http://localhost:3000/telemetry'})
expect(project.reportTargetUrl).toBeDefined()

const snapshot = synthSnapshot(project)
const telemetryWorkflow = snapshot[telemetryWorkflowPath]
expect(telemetryWorkflow).toBeDefined()
const {IS_OTTOFELLER_TEMPLATES_TELEMETRY_COLLECTED} = YAML.parse(telemetryWorkflow).jobs.telemetry.env
expect(IS_OTTOFELLER_TEMPLATES_TELEMETRY_COLLECTED).toBeTruthy()
})

test('sets up telemetry authorization', () => {
const project = new TestProject({})
const reportTargetAuthHeaderName = 'Some-Header'
const reportTargetAuthTokenVar = 'Some-Secret'

setupTelemetry(project, {
isTelemetryEnabled: true,
reportTargetUrl: 'http://localhost:3000/telemetry',
reportTargetAuthHeaderName,
reportTargetAuthTokenVar,
})

expect(project.reportTargetUrl).toBeDefined()
expect(project.reportTargetAuthHeaderName).toBeDefined()

const snapshot = synthSnapshot(project)
const telemetryWorkflow = snapshot['.github/workflows/telemetry.yml']
const telemetryWorkflow = snapshot[telemetryWorkflowPath]
expect(telemetryWorkflow).toBeDefined()
const {env} = YAML.parse(telemetryWorkflow).jobs.telemetry
expect(env[reportTargetAuthToken]).toEqual(`\${{ secrets.${reportTargetAuthTokenVar} }}`)
})
})

class TestProject extends NodeProject implements IWithTelemetryReportUrl {
readonly telemetryReportUrl?: string
readonly reportTargetUrl?: string
readonly reportTargetAuthHeaderName?: string

constructor(options: Partial<NodeProjectOptions & WithTelemetry> = {}) {
super({
Expand Down
43 changes: 32 additions & 11 deletions src/common/telemetry/collect-telemetry/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fetch, {RequestInfo, RequestInit, Response} from 'node-fetch'
import {NodeProject, NodeProjectOptions} from 'projen/lib/javascript'
import {collectTelemetry} from '..'
import {IWithTelemetryReportUrl, WithTelemetry, setupTelemetry} from '../..'
import {telemetryEnableEnvVar} from '../collect-telemetry'
import {reportTargetAuthToken, telemetryEnableEnvVar} from '../collect-telemetry'

jest.mock('child_process')
jest.mock('node-fetch')
Expand All @@ -17,7 +17,7 @@ jest.mock('fs', () => ({
describe('collectTelemetry function', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TS is not aware of the Jest mock, thus casting is needed
const mockedNodeFetch = fetch as unknown as jest.Mock<Promise<Response>, [url: RequestInfo, init: RequestInit]>
const telemetryOptions: WithTelemetry = {isTelemetryEnabled: true, telemetryUrl: 'http://localhost:3000/telemetry'}
const telemetryOptions: WithTelemetry = {isTelemetryEnabled: true, reportTargetUrl: 'http://localhost:3000/telemetry'}

const mockStringify = jest.fn<
string,
Expand All @@ -30,7 +30,7 @@ describe('collectTelemetry function', () => {

const testStringifyValue = 'stringifiedPayload'
JSON.stringify = mockStringify
const fetchOptions = {method: 'post', body: testStringifyValue}
const fetchOptions = {method: 'post', body: testStringifyValue, headers: {}}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TS is not aware of the Jest mock, thus casting is needed
const mockedExecSync = execSync as unknown as jest.Mock<string, [string]>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TS is not aware of the Jest mock, thus casting is needed
Expand Down Expand Up @@ -61,13 +61,33 @@ describe('collectTelemetry function', () => {
expect(mockedNodeFetch).not.toBeCalled()
})

test('sets auth header if it is defined in options', () => {
const telemetryAuthHeader = 'Auth'
const telemetryAuthTokenVar = 'TELEMETRY_TOKEN'
const telemetryAuthTokenValue = 'some-token'
Object.assign(process.env, {[reportTargetAuthToken]: telemetryAuthTokenValue})

const project = new TestProject({
...telemetryOptions,
reportTargetAuthHeaderName: telemetryAuthHeader,
reportTargetAuthTokenVar: telemetryAuthTokenVar,
})

collectTelemetry(project)

const headers = {[telemetryAuthHeader]: telemetryAuthTokenValue}
expect(mockedNodeFetch).toHaveBeenCalledWith(telemetryOptions.reportTargetUrl, {...fetchOptions, headers})

delete process.env[telemetryAuthHeader]
})

test('collects templates version', () => {
const templateVersion = '1.1.0'
const project = new TestProject({...telemetryOptions, devDeps: [`@ottofeller/templates@${templateVersion}`]})
collectTelemetry(project)

expect(mockStringify).lastCalledWith(expect.objectContaining({templateVersion}))
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})

test('collects git URLs', () => {
Expand All @@ -83,7 +103,7 @@ describe('collectTelemetry function', () => {

const gitUrls = ['https://github.com/ottofeller/sampleProject.git']
expect(mockStringify).lastCalledWith(expect.objectContaining({gitUrls}))
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})

test('collects escape hatches utilized in projenrc file', () => {
Expand Down Expand Up @@ -126,7 +146,7 @@ describe('collectTelemetry function', () => {
collectTelemetry(project)

expect(mockStringify).lastCalledWith(expect.objectContaining({escapeHatches}))
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})

test('does not collect empty escape hatches', () => {
Expand All @@ -145,7 +165,7 @@ describe('collectTelemetry function', () => {
collectTelemetry(project)

expect(mockStringify).toHaveBeenLastCalledWith(expect.objectContaining({escapeHatches}))
expect(mockedNodeFetch).toHaveBeenLastCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toHaveBeenLastCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})

test('collects GitHub workflow data', () => {
Expand Down Expand Up @@ -213,7 +233,7 @@ describe('collectTelemetry function', () => {
}

expect(mockStringify).lastCalledWith(expect.objectContaining({workflows}))
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})

describe('collects errors', () => {
Expand All @@ -228,7 +248,7 @@ describe('collectTelemetry function', () => {
]

expect(mockStringify).lastCalledWith(expect.objectContaining({errors}))
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})

test('if failed to read projenrc file', () => {
Expand All @@ -242,13 +262,14 @@ describe('collectTelemetry function', () => {
]

expect(mockStringify).lastCalledWith(expect.objectContaining({errors}))
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.telemetryUrl, fetchOptions)
expect(mockedNodeFetch).toBeCalledWith(telemetryOptions.reportTargetUrl, fetchOptions)
})
})
})

class TestProject extends NodeProject implements IWithTelemetryReportUrl {
readonly telemetryReportUrl?: string
readonly reportTargetUrl?: string
readonly reportTargetAuthHeaderName?: string

constructor(options: Partial<NodeProjectOptions & WithTelemetry> = {}) {
super({
Expand Down
12 changes: 10 additions & 2 deletions src/common/telemetry/collect-telemetry/collect-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ interface TelemetryPayload {
}

export const telemetryEnableEnvVar = 'IS_OTTOFELLER_TEMPLATES_TELEMETRY_COLLECTED' as const
export const reportTargetAuthToken = 'TELEMETRY_AUTH_TOKEN' as const

export const collectTelemetry = async (project: NodeProject & IWithTelemetryReportUrl) => {
if (!project.telemetryReportUrl || !process.env[telemetryEnableEnvVar]) {
if (!project.reportTargetUrl || !process.env[telemetryEnableEnvVar]) {
return
}

Expand Down Expand Up @@ -171,7 +172,14 @@ export const collectTelemetry = async (project: NodeProject & IWithTelemetryRepo
try {
const body = JSON.stringify(payload)
project.logger.info('Collected telemetry:', body)
const {status, statusText} = await fetch(project.telemetryReportUrl!, {method: 'post', body})
const headers: Record<string, string> = {}
const {reportTargetAuthHeaderName} = project

if (reportTargetAuthHeaderName) {
headers[reportTargetAuthHeaderName] = process.env[reportTargetAuthToken]!
}

const {status, statusText} = await fetch(project.reportTargetUrl!, {method: 'post', body, headers})
project.logger.info('Telemetry endpoint responded with status', status, statusText)
} catch (e) {
project.logger.error('Telemetry serialization or network error', e)
Expand Down
7 changes: 6 additions & 1 deletion src/common/telemetry/i-with-telemetry-report-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@ export interface IWithTelemetryReportUrl {
/**
* URL used for telemetry.
*/
readonly telemetryReportUrl?: string
readonly reportTargetUrl?: string

/**
* Authorization header name for telemetry
*/
readonly reportTargetAuthHeaderName?: string
}
27 changes: 21 additions & 6 deletions src/common/telemetry/setup-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,35 @@ export const setupTelemetry = (
project: NodeProject & Writeable<IWithTelemetryReportUrl>,
options: WithTelemetry & TelemetryWorkflowOptions,
) => {
const {isTelemetryEnabled = false, telemetryUrl} = options
const {isTelemetryEnabled = false, reportTargetUrl, reportTargetAuthHeaderName, reportTargetAuthTokenVar} = options
const anyOfOptionalTelemetryParams = reportTargetUrl || reportTargetAuthHeaderName || reportTargetAuthTokenVar

if (!isTelemetryEnabled && telemetryUrl) {
throw Error('Telemetry is disabled, thus "telemetryUrl" won\'t have any effect.')
if (!isTelemetryEnabled && anyOfOptionalTelemetryParams) {
throw new Error(
'Telemetry is disabled, thus "telemetryUrl", "telemetryAuthHeader" or "telemetryAuthTokenVar" won\'t have any effect.',
)
}

if (!isTelemetryEnabled) {
return
}

if (!telemetryUrl) {
throw Error('A valid URL is required to be set for telemetry.')
if (!reportTargetUrl) {
throw new Error('A valid URL is required to be set for telemetry.')
}

if (
(reportTargetAuthHeaderName && !reportTargetAuthTokenVar) ||
(!reportTargetAuthHeaderName && reportTargetAuthTokenVar)
) {
throw new Error('"telemetryAuthHeader" and "telemetryAuthTokenVar" options should be set together')
}

project.reportTargetUrl = reportTargetUrl

if (reportTargetAuthHeaderName) {
project.reportTargetAuthHeaderName = reportTargetAuthHeaderName
}

project.telemetryReportUrl = telemetryUrl
TelemetryWorkflow.addToProject(project, options)
}
53 changes: 29 additions & 24 deletions src/common/telemetry/telemetry-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import {Component, ProjenrcFile} from 'projen'
import type {GitHub} from 'projen/lib/github'
import {NodeProject, NodeProjectOptions} from 'projen/lib/javascript'
import {runScriptJob} from '../github'
import {telemetryEnableEnvVar} from './collect-telemetry'
import {reportTargetAuthToken, telemetryEnableEnvVar} from './collect-telemetry'
import type {WithTelemetry} from './with-telemetry'

/**
* Options for PullRequestLint
*/
export interface TelemetryWorkflowOptions
extends Partial<Pick<NodeProject, 'runScriptCommand'>>,
Pick<NodeProjectOptions, 'workflowNodeVersion'> {
Pick<NodeProjectOptions, 'workflowNodeVersion'>,
Pick<WithTelemetry, 'reportTargetAuthTokenVar'> {
/**
* Project name - used to construct workflow name for subprojects
*/
Expand All @@ -32,44 +34,47 @@ export class TelemetryWorkflow extends Component {
throw new Error('TelemetryWorkflow works only with instances of NodeProject.')
}

const rcFilePath = ProjenrcFile.of(project)?.filePath

if (!rcFilePath) {
throw new Error('TelemetryWorkflow works only with projects that have a ProjenrcFile component.')
}

super(project)

const workingDirectory = options.outdir
const rcFile = ProjenrcFile.of(project)?.filePath
const paths = rcFile ? [rcFile] : undefined
const nodeVersion = options.workflowNodeVersion ?? project.package.minNodeVersion
const workflow = githubInstance.addWorkflow('telemetry')
workflow.on({pullRequest: {paths, types: ['opened', 'synchronize']}})
workflow.on({pullRequest: {paths: [rcFilePath], types: ['opened', 'synchronize']}})

const telemetryJob = runScriptJob({
command: 'default',
workingDirectory,
projectPackage: project.package,
runScriptCommand: project.runScriptCommand,
nodeVersion,
})
const env: Record<string, string> = {[telemetryEnableEnvVar]: '1'}
const {reportTargetAuthTokenVar} = options

workflow.addJob('telemetry', {...telemetryJob, env: {[telemetryEnableEnvVar]: '1'}})
if (reportTargetAuthTokenVar) {
env[reportTargetAuthToken] = `\${{ secrets.${reportTargetAuthTokenVar} }}`
}

workflow.addJob('telemetry', {
env,
...runScriptJob({
command: 'default',
workingDirectory,
projectPackage: project.package,
runScriptCommand: project.runScriptCommand,
nodeVersion,
}),
})
}

/**
* Creates a workflow within the given project
* or within the project parent (for subprojects).
*/
static addToProject(project: NodeProject, options: TelemetryWorkflowOptions) {
const {outdir, workflowNodeVersion} = options
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also added a commit that removes any logic related to parent-subproject relation, since we aim for projenrc file that is only present on root project in a nested setup.


if (project.github) {
new TelemetryWorkflow(project.github, {outdir, workflowNodeVersion})
if (!project.github) {
return
}

if (project.parent && project.parent instanceof NodeProject && project.parent.github) {
new TelemetryWorkflow(project.parent.github, {
name: `telemetry-${options.name}`,
outdir,
workflowNodeVersion,
})
}
new TelemetryWorkflow(project.github, options)
}
}
13 changes: 12 additions & 1 deletion src/common/telemetry/with-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,16 @@ export interface WithTelemetry {
/**
* Endpoint URL to send telemetry data to.
*/
readonly telemetryUrl?: string
readonly reportTargetUrl?: string

/**
* Authorization header name for telemetry
*/
readonly reportTargetAuthHeaderName?: string

/**
* The name of env var to extract header value from.
* The value is expected to be stored in a CI secret.
*/
readonly reportTargetAuthTokenVar?: string
}
Loading
Loading