Skip to content

Commit

Permalink
Merge pull request #280 from atlassian/NONE-test-analytics
Browse files Browse the repository at this point in the history
analytics
  • Loading branch information
joshkay10 authored Feb 28, 2024
2 parents cf237a8 + 666953b commit 5fd9137
Show file tree
Hide file tree
Showing 11 changed files with 2,666 additions and 2,747 deletions.
2 changes: 2 additions & 0 deletions app/manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ permissions:
- https://support.atlassian.com
backend:
- https://app.launchdarkly.com/api/v2/flags/Jenkins
- https://as.atlassian.com
- https://as.staging.atl-paas.net
content:
styles:
- unsafe-inline
Expand Down
3,165 changes: 1,391 additions & 1,774 deletions app/package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,21 @@
"prepare": "cd ../ && husky install"
},
"dependencies": {
"@atlassiansox/analytics-node-client": "^4.1.1",
"@forge/api": "3.0.0",
"@forge/metrics": "^0.2.2",
"@forge/resolver": "1.5.14",
"atlassian-jwt": "^2.0.2",
"crypto": "^1.0.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"node-fetch": "^2.6.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/jest": "27.5.2",
"@types/jest-when": "3.5.2",
"@types/jsonwebtoken": "8.5.9",
"@types/lodash": "^4.14.197",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",
"eslint": "8.50.0",
Expand Down
59 changes: 40 additions & 19 deletions app/src/analytics/analytics-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isProductionEnv, createAnonymousId, getAccountDetails } from './analytics-client';
import fetch from 'node-fetch';
import {
sendAnalytics,
createAnonymousId,
createTrackEvent,
createMessageId,
isProductionEnv
} from './analytics-client';

jest.mock('./analytics-client', () => ({
...jest.requireActual('./analytics-client'),
getAnalyticsClient: jest.fn().mockReturnValue({ cats: 'ssss' })
}));

jest.mock('@atlassiansox/analytics-node-client');
jest.mock('node-fetch', () => jest.fn());

describe('isProductionEnv', () => {
test('should return true for production environment', () => {
Expand All @@ -23,18 +25,37 @@ describe('createAnonymousId', () => {
expect(result).toBe('620ae460798e1f4cab44c44f3085620284f0960a276bbc3f0bd416449df14dbe');
});
});
describe('getAccountDetails', () => {
test('should return anonymousId when no accountId', () => {
const result = getAccountDetails('', 'ipAddress');
expect(result).toEqual({
anonymousId: '095cf3ff5683bd7c1db48018af7d0bee79205bcafb47a09b97f9faf0bcee4800'
});

describe('sendAnalytics', () => {
it('should send analytics', async () => {
process.env.NODE_ENV = 'prod';
process.env.ANALYTICS_URL = 'epic-url';
const eventPayload = { eventName: 'event', action: 'action', actionSubject: 'subject' };
await sendAnalytics('cloudId', eventPayload, 'accountId', 'anonymousId');
expect(fetch).toHaveBeenCalled();
});
});

describe('createTrackEvent', () => {
it('should create an event', () => {
const eventPayload = { eventName: 'event', action: 'action', actionSubject: 'subject' };
const event = createTrackEvent('cloudId', eventPayload, 'accountId', 'anonymousId');
expect(event).toHaveProperty('userId', 'accountId');
expect(event).toHaveProperty('anonymousId');
expect(event).toHaveProperty('event');
expect(event).toHaveProperty('properties');
expect(event).toHaveProperty('type', 'track');
expect(event).toHaveProperty('timestamp');
expect(event).toHaveProperty('messageId');
});
test('should return accountId object', () => {
const result = getAccountDetails('testAccount');
expect(result).toEqual({
userIdType: 'atlassianAccount',
userId: 'testAccount',
});
});

describe('createMessageId', () => {
it('should create a messageId', () => {
const messageId = createMessageId();
expect(messageId).toBeDefined();
// This regex validates a UUID
const uuidRegex = /^j4j-server-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
expect(messageId).toMatch(uuidRegex);
});
});
211 changes: 144 additions & 67 deletions app/src/analytics/analytics-client.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,168 @@
import crypto from 'crypto';
import fetch from 'node-fetch';
import { v4 as uuidv4 } from 'uuid';

interface AnalyticsAttributes {
[key: string]: any;
[key: string]: any;
}

interface EventPayload {
eventName: string,
action: string,
actionSubject: string,
attributes?: AnalyticsAttributes
interface Metadata {
nodeVersion: string;
version: string;
}

interface Context {
library: any
}

enum EnvType {
LOCAL = 'local',
DEV = 'dev',
STAGING = 'staging',
PROD = 'prod'
interface EventPayload {
action: string,
actionSubject: string,
attributes?: AnalyticsAttributes
}

interface Event {
userId?: string;
anonymousId: string;
event: string;
properties: {
action: string;
actionSubject: string;
attributes: Record<string, string>;
env: string;
eventType: string;
origin: string;
product: string;
tenantId: string;
tenantIdType: string;
source: string;
userIdType?: string;
};
type: string;
timestamp: string;
_metadata: Metadata;
context: Context;
messageId: string;
}

interface Payload {
batch: Event[];
timestamp: string;
sentAt: string;
}

interface Options {
method: string;
body: string;
headers: {
'Content-Type': string;
};
}

const environments = {
LOCAL: 'local',
DEV: 'dev',
STAGING: 'stg',
PROD: 'prod'
};

const TRACK_EVENT_TYPE = 'track';
const PRODUCT = 'jenkinsForJira';
const ORIGIN = 'server';
const TENANT_ID_TYPE = 'cloudId';

// eslint-disable-next-line max-len
export const sendAnalytics = async (cloudId: string, eventPayload: EventPayload, accountId?: string, anonymousId?: string): Promise<void> => {
sendEvent(cloudId, eventPayload, accountId, anonymousId)
.then(() => console.log('Analytics event processed'))
.catch((e) => console.error({ e }, 'Failed to send analytics event'));
console.info('Analytics Request');

sendEvent(cloudId, eventPayload, accountId, anonymousId)
.then(() => console.info('Analytics event processed'))
.catch((e) => console.error({ e }, 'Failed to send analytics event'));
};

// eslint-disable-next-line max-len
const getAnalyticsEnvironmentUrl = () => {
if (isProductionEnv()) {
return process.env.ANALYTICS_URL;
}
return process.env.ANALYTICS_STAGE_URL;
};

// eslint-disable-next-line max-len,@typescript-eslint/no-unused-vars
const sendEvent = async (cloudId: string, eventPayload: EventPayload, accountId?: string, anonymousId?: string): Promise<void> => {
const analyticsClient = await getAnalyticsClient();
const {
eventName, attributes, actionSubject, action
} = eventPayload;

if (!analyticsClient || !isProductionEnv()) {
console.warn('Analytics sendEvent skipped: @atlassiansox/analytics-node-client module not found or environment not production.');
return;
}

const accountDetails = getAccountDetails(accountId, anonymousId);

await analyticsClient.sendTrackEvent({
...accountDetails,
tenantIdType: 'cloudId',
tenantId: cloudId,
trackEvent: {
source: eventName,
action: action || 'unknown',
actionSubject: actionSubject || eventName,
attributes: {
...attributes
}
}
});
const url = getAnalyticsEnvironmentUrl();
if (!url) {
console.warn('No analytics path found.');
return;
}
const trackEvent: Event = createTrackEvent(cloudId, eventPayload, accountId, anonymousId);
const timestamp = new Date().toISOString();
const payload: Payload = {
batch: [trackEvent],
timestamp,
sentAt: timestamp
};

const options: Options = {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
}
};

console.info('Sending Analytics');

await fetch(url, options);
};

export const isProductionEnv = (): boolean => {
const env = (process.env.NODE_ENV || '').toLowerCase();
return ['production', 'prod'].includes(env);
// eslint-disable-next-line max-len
export const createTrackEvent = (cloudId: string, eventPayload: EventPayload, accountId?: string, anonymousId?: string): Event => {
const timestamp = new Date().toISOString();
const {
attributes, actionSubject, action
} = eventPayload;
return {
userId: accountId,
anonymousId: createAnonymousId(anonymousId || 'default'),
event: `${actionSubject} ${action}`,
properties: {
action,
actionSubject,
attributes: attributes || {},
product: PRODUCT,
env: isProductionEnv() ? environments.PROD : environments.STAGING,
eventType: TRACK_EVENT_TYPE,
userIdType: accountId ? 'atlassianAccount' : undefined,
tenantIdType: TENANT_ID_TYPE,
tenantId: cloudId,
source: 'server',
origin: ORIGIN
},
type: TRACK_EVENT_TYPE,
timestamp,
_metadata: {
nodeVersion: process.versions.node,
version: '0'
},
context: {
library: {
name: 'analytics-node'
}
},
messageId: createMessageId()
};
};

export const getAccountDetails = (accountId?: string, anonymousId?: string) => {
if (accountId) {
return {
userIdType: 'atlassianAccount',
userId: accountId,
};
}
const hashedAnonymousId = createAnonymousId(anonymousId || 'default');
return { anonymousId: hashedAnonymousId };
export const isProductionEnv = (): boolean => {
const env = (process.env.NODE_ENV || '').toLowerCase();
return ['production', 'prod'].includes(env);
};

export const createAnonymousId = (input: string): string => {
const hash = crypto.createHash('sha256').update(input).digest('hex');
return hash;
const hash = crypto.createHash('sha256').update(input).digest('hex');
return hash;
};

export const getAnalyticsClient = async (): Promise<any> => {
try {
const { analyticsClient } = await import('@atlassiansox/analytics-node-client');

const analyticsNodeClient = analyticsClient({
env: EnvType.PROD,
product: 'jenkinsForJira'
});

return analyticsNodeClient;
} catch (error) {
return null;
}
export const createMessageId = () => {
return `j4j-server-${uuidv4()}`;
};
13 changes: 8 additions & 5 deletions app/src/analytics/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ export enum AnalyticsEventTypes {
OperationalEvent = 'operational' // help measure usages or performance of implementation detail
}

export enum AnalyticsTrackEventsEnum {
ConfigDataReceivedName = 'ConfigDataReceived',
DisconnectedServerName = 'DisconnectedServer',
ConnectionCreatedName = 'ConnectionCreated'
export enum AnalyticsTrackEventActionsEnum {
User = 'User action',
RequestHandling = 'Request handling'
}

export enum AnalyticsOperationalEventsEnum {}
export enum AnalyticsTrackEventActionSubjectsEnum {
ConnectionCreated = 'created new Jenkins server',
DisconnectedServer = 'disconnected Jenkins server',
ConfigDataReceived = 'received Jenkins plugin config data'
}
4 changes: 4 additions & 0 deletions app/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
export interface EnvVars {
LAUNCHDARKLY_API_KEY: string;
LAUNCHDARKLY_APP_NAME: string;
ANALYTICS_URL?: string;
ANALYTICS_STAGE_URL?: string;
JENKINS_ENV: string;
}

const envVars: EnvVars = {
LAUNCHDARKLY_API_KEY: process.env.LAUNCHDARKLY_API_KEY || '',
LAUNCHDARKLY_APP_NAME: process.env.LAUNCHDARKLY_APP_NAME || 'jenkins-for-jira',
ANALYTICS_URL: process.env.ANALYTICS_URL || '',
ANALYTICS_STAGE_URL: process.env.ANALYTICS_STAGE_URL || '',
JENKINS_ENV: process.env.JENKINS_ENV || ''
};

Expand Down
7 changes: 3 additions & 4 deletions app/src/storage/connect-jenkins-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SECRET_STORAGE_KEY_PREFIX, SERVER_STORAGE_KEY_PREFIX } from './constant
import { JenkinsServerStorageError } from '../common/error';
import { Logger } from '../config/logger';
import { sendAnalytics } from '../analytics/analytics-client';
import { AnalyticsTrackEventsEnum } from '../analytics/analytics-events';
import { AnalyticsTrackEventActionSubjectsEnum, AnalyticsTrackEventActionsEnum } from '../analytics/analytics-events';
import { metricFailedRequests, metricSuccessfulRequests } from '../common/metric-names';

const connectJenkinsServer = async (
Expand Down Expand Up @@ -35,9 +35,8 @@ const connectJenkinsServer = async (

const sendConnectAnalytics = async (cloudId: string, accountId: string) => {
const eventPayload = {
eventName: AnalyticsTrackEventsEnum.ConnectionCreatedName,
action: 'Connected new Jenkins server',
actionSubject: 'User action'
action: AnalyticsTrackEventActionsEnum.User,
actionSubject: AnalyticsTrackEventActionSubjectsEnum.ConnectionCreated
};

await sendAnalytics(cloudId, eventPayload, accountId);
Expand Down
Loading

0 comments on commit 5fd9137

Please sign in to comment.