diff --git a/.env.sample b/.env.sample index 87180a6a..bb7e7e22 100644 --- a/.env.sample +++ b/.env.sample @@ -77,8 +77,6 @@ CLOUDPAYMENTS_SECRET= # INN of legal entity for CloudKassir LEGAL_ENTITY_INN= -# Token for Amplitude analytics -AMPLITUDE_TOKEN= # AWS S3 Bucket Configuration AWS_S3_ACCESS_KEY_ID= diff --git a/index.ts b/index.ts index 5832cf8d..591016ab 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,9 @@ if (process.env.HAWK_CATCHER_TOKEN) { HawkCatcher.init({ token: process.env.HAWK_CATCHER_TOKEN, release: `${name}-${version}`, + breadcrumbs: { + maxBreadcrumbs: 100 + } }); } diff --git a/package.json b/package.json index 29e36e9b..52feab40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.2", + "version": "1.4.6", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -38,12 +38,11 @@ }, "dependencies": { "@ai-sdk/openai": "^2.0.64", - "@amplitude/node": "^1.10.0", "@graphql-tools/merge": "^8.3.1", "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", - "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.5.6", + "@hawk.so/nodejs": "^3.3.1", + "@hawk.so/types": "^0.5.8", "@n1ru4l/json-patch-plus": "^0.2.0", "@node-saml/node-saml": "^5.0.1", "@octokit/oauth-methods": "^4.0.0", diff --git a/src/integrations/github/routes.ts b/src/integrations/github/routes.ts index f000ca24..8144ec34 100644 --- a/src/integrations/github/routes.ts +++ b/src/integrations/github/routes.ts @@ -522,15 +522,15 @@ export function createGitHubRouter(factories: ContextFactories): express.Router * just log query parameters and respond with 200 without signature validation. */ if (req.method !== 'POST') { + // eslint-disable-next-line @typescript-eslint/camelcase, camelcase const { code, installation_id, setup_action, state, ...restQuery } = req.query as Record; + // eslint-disable-next-line @typescript-eslint/camelcase, camelcase if (code || installation_id || state || setup_action) { - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase log('info', `${WEBHOOK_LOG_PREFIX}Received non-POST request on /webhook with OAuth-like params`, { - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase code, - installation_id, - setup_action, + installation_id, // eslint-disable-line @typescript-eslint/camelcase, camelcase + setup_action, // eslint-disable-line @typescript-eslint/camelcase, camelcase state, query: restQuery, }); diff --git a/src/metrics/graphql.ts b/src/metrics/graphql.ts index b1c490e0..046d903a 100644 --- a/src/metrics/graphql.ts +++ b/src/metrics/graphql.ts @@ -1,7 +1,7 @@ import client from 'prom-client'; import { ApolloServerPlugin, GraphQLRequestContext, GraphQLRequestListener } from 'apollo-server-plugin-base'; import { GraphQLError } from 'graphql'; - +import HawkCatcher from '@hawk.so/nodejs'; /** * GraphQL operation duration histogram * Tracks GraphQL operation duration by operation name and type @@ -71,15 +71,31 @@ export const graphqlMetricsPlugin: ApolloServerPlugin = { }, async willSendResponse(ctx: GraphQLRequestContext): Promise { - const duration = (Date.now() - startTime) / 1000; + const durationMs = Date.now() - startTime; + const duration = durationMs / 1000; gqlOperationDuration .labels(operationName, operationType) .observe(duration); + const hasErrors = ctx.errors && ctx.errors.length > 0; + + HawkCatcher.breadcrumbs.add({ + type: 'request', + category: 'GraphQL Operation', + message: `${operationType} ${operationName} ${durationMs}ms${hasErrors ? ` [${ctx.errors!.length} error(s)]` : ''}`, + level: hasErrors ? 'error' : 'debug', + data: { + operationName: { value: operationName }, + operationType: { value: operationType }, + durationMs: { value: durationMs }, + ...(hasErrors && { errors: { value: ctx.errors!.map((e: GraphQLError) => e.message).join('; ') } }), + }, + }); + // Track errors if any - if (ctx.errors && ctx.errors.length > 0) { - ctx.errors.forEach((error: GraphQLError) => { + if (hasErrors) { + ctx.errors!.forEach((error: GraphQLError) => { const errorType = error.extensions?.code || error.name || 'unknown'; gqlOperationErrors diff --git a/src/metrics/mongodb.ts b/src/metrics/mongodb.ts index 44fd608c..a5c1f51d 100644 --- a/src/metrics/mongodb.ts +++ b/src/metrics/mongodb.ts @@ -1,6 +1,7 @@ import promClient from 'prom-client'; import { MongoClient, MongoClientOptions } from 'mongodb'; import { Effect, sgr } from '../utils/ansi'; +import HawkCatcher from '@hawk.so/nodejs'; /** * MongoDB command duration histogram @@ -306,6 +307,19 @@ export function setupMongoMetrics(client: MongoClient): void { .labels(metadata.commandName, metadata.collectionFamily, metadata.db) .observe(duration); + HawkCatcher.breadcrumbs.add({ + type: 'request', + category: 'MongoDB Operation', + message: `${metadata.db}.${metadata.collectionFamily}.${metadata.commandName} ${event.duration}ms`, + level: 'debug', + data: { + db: metadata.db, + collection: metadata.collectionFamily, + command: metadata.commandName, + durationMs: { value: event.duration }, + }, + }); + // Clean up metadata // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (client as any)[metadataKey]; @@ -337,6 +351,22 @@ export function setupMongoMetrics(client: MongoClient): void { .labels(metadata.commandName, errorCode) .inc(); + const errorMsg = (event.failure as any)?.message || 'Unknown error'; + + HawkCatcher.breadcrumbs.add({ + type: 'error', + category: 'MongoDB operation', + message: `${metadata.db}.${metadata.collectionFamily}.${metadata.commandName} FAILED: ${errorMsg} ${event.duration}ms`, + level: 'error', + data: { + db: metadata.db, + collection: metadata.collectionFamily, + command: metadata.commandName, + durationMs: { value: event.duration }, + errorCode, + }, + }); + // Clean up metadata // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (client as any)[metadataKey]; diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index ba3ee1de..8e1869f8 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -3,7 +3,6 @@ import UserModel from './user'; import { Collection, Db, OptionalId } from 'mongodb'; import DataLoaders from '../dataLoaders'; import { UserDBScheme } from '@hawk.so/types'; -import { Analytics, AnalyticsEventTypes } from '../utils/analytics'; /** * Users factory to work with User Model @@ -91,14 +90,6 @@ export default class UsersFactory extends AbstractModelFactory { export async function publish(exchange: string, route: string, message: string, options?: Options.Publish): Promise { try { await channel.publish(exchange, route, Buffer.from(message), options); + HawkCatcher.breadcrumbs.add({ + type: 'request', + category: 'RabbitMQ Operation', + message: `AMQP publish ${exchange || '(default)'}/${route}`, + level: 'debug', + data: { + exchange: { value: exchange }, + route: { value: route }, + }, + }); debug(`Message sent: ${message}`); } catch (err) { + HawkCatcher.breadcrumbs.add({ + type: 'error', + category: 'RabbitMQ Operation', + message: `AMQP publish FAILED ${exchange || '(default)'}/${route}: ${(err as Error).message}`, + level: 'error', + data: { + exchange: { value: exchange }, + route: { value: route }, + }, + }); HawkCatcher.send(err as Error); console.log('Message was rejected:', (err as Error).stack); } diff --git a/src/resolvers/project.js b/src/resolvers/project.js index aa16734e..c1a2767c 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -421,7 +421,7 @@ module.exports = { */ const taskManager = project.taskManager; - if (taskManager && taskManager.type === 'github' && taskManager.config?.installationId) { + if (taskManager && taskManager.type === 'github' && taskManager.config && taskManager.config.installationId) { const githubService = new GitHubService(); await githubService.deleteInstallation(taskManager.config.installationId); diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c2ac3a2d..c200de96 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -116,29 +116,6 @@ type EventUser { photo: String } -""" -Breadcrumb severity level -""" -enum BreadcrumbLevel { - fatal - error - warning - info - debug -} - -""" -Breadcrumb type - controls categorization and UI appearance -""" -enum BreadcrumbType { - default - request - ui - navigation - logic - error -} - """ Single breadcrumb entry - represents an event that occurred before the error """ @@ -149,9 +126,11 @@ type Breadcrumb { timestamp: Float! """ - Type of breadcrumb - controls UI categorization + Type of breadcrumb - controls UI categorization. + Common values: default, request, ui, navigation, logic, error. + Accepts any string since SDK users may send custom types. """ - type: BreadcrumbType + type: String """ Category of the event - more specific than type @@ -164,9 +143,11 @@ type Breadcrumb { message: String """ - Severity level of the breadcrumb + Severity level of the breadcrumb. + Common values: fatal, error, warning, info, debug. + Accepts any string since SDK users may send custom levels. """ - level: BreadcrumbLevel + level: String """ Arbitrary key-value data associated with the breadcrumb diff --git a/src/utils/analytics/amplitude.ts b/src/utils/analytics/amplitude.ts deleted file mode 100644 index dc542c1b..00000000 --- a/src/utils/analytics/amplitude.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Amplitude from '@amplitude/node'; -import { AnalyticsEvent } from './events'; - -/** - * Get the token from env vars - */ -const AMPLITUDE_TOKEN = process.env.AMPLITUDE_TOKEN || ''; - -/** - * Initialize Amplitude - */ -const amplitude = Amplitude.init(AMPLITUDE_TOKEN); - -/** - * Export available analytics methods - */ -export const Analytics = { - /** - * Send an event to analytics server - * - * @param event - event to be logged - * @returns Promise - */ - logEvent: (event: AnalyticsEvent): Promise => { - return amplitude.logEvent(event); - }, -}; diff --git a/src/utils/analytics/events.ts b/src/utils/analytics/events.ts deleted file mode 100644 index b5342660..00000000 --- a/src/utils/analytics/events.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Event } from '@amplitude/node'; - -/** - * Define available metrics - */ -export enum AnalyticsEventTypes { - NEW_USER_REGISTERED = 'new user registered', - - WORKSPACE_CREATED = 'workspace created', - - PROJECT_CREATED = 'project created', -} - -/** - * Define analytics event type - */ -export type AnalyticsEvent = Omit & { 'event_type': AnalyticsEventTypes }; diff --git a/src/utils/analytics/index.ts b/src/utils/analytics/index.ts deleted file mode 100644 index 40d26b1a..00000000 --- a/src/utils/analytics/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Analytics } from './amplitude'; -export { AnalyticsEventTypes } from './events'; diff --git a/test/__mocks__/github-service.ts b/test/__mocks__/github-service.ts new file mode 100644 index 00000000..04ff8529 --- /dev/null +++ b/test/__mocks__/github-service.ts @@ -0,0 +1,5 @@ +export const deleteInstallationMock = jest.fn().mockResolvedValue(undefined); + +export const GitHubService = jest.fn().mockImplementation(() => ({ + deleteInstallation: deleteInstallationMock, +})); diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 81094183..03eacc94 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -480,7 +480,7 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain('http://localhost:8080/'); - expect(response.body).toContain('error=Missing+or+invalid+OAuth+code'); + expect(response.body).toContain('apiError=Missing+or+invalid+OAuth+code'); }); it('should redirect with error when state is missing', async () => { @@ -501,7 +501,7 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain('http://localhost:8080/'); - expect(response.body).toContain('error=Missing+or+invalid+state'); + expect(response.body).toContain('apiError=Missing+or+invalid+state'); }); it('should redirect with error when state is invalid or expired', async () => { @@ -525,7 +525,7 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain('http://localhost:8080/'); - expect(response.body).toContain('error=Invalid+or+expired+state'); + expect(response.body).toContain('apiError=Invalid+or+expired+state'); expect(mockGetState).toHaveBeenCalledWith(state); }); diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index bda54a40..8f50acbc 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -3,6 +3,11 @@ import { ObjectId } from 'mongodb'; import { ProjectDBScheme, ProjectTaskManagerConfig } from '@hawk.so/types'; import { ResolverContextWithUser } from '../../src/types/graphql'; import { ApolloError, UserInputError } from 'apollo-server-express'; + +jest.mock('../../src/integrations/github/service', () => require('../__mocks__/github-service')); +// eslint-disable-next-line @typescript-eslint/no-var-requires +import { deleteInstallationMock, GitHubService } from '../__mocks__/github-service'; + // @ts-expect-error - CommonJS module, TypeScript can't infer types properly import projectResolverModule from '../../src/resolvers/project'; @@ -141,6 +146,8 @@ describe('Project Resolver - Task Manager Mutations', () => { )) as { taskManager: ProjectTaskManagerConfig | null }; expect(context.factories.projectsFactory.findById).toHaveBeenCalledWith(mockProject._id.toString()); + expect(GitHubService).toHaveBeenCalledTimes(1); + expect(deleteInstallationMock).toHaveBeenCalledWith('123456'); expect(mockProject.updateProject).toHaveBeenCalledWith({ taskManager: null, }); @@ -217,6 +224,8 @@ describe('Project Resolver - Task Manager Mutations', () => { context )) as { taskManager: ProjectTaskManagerConfig | null }; + expect(GitHubService).not.toHaveBeenCalled(); + expect(deleteInstallationMock).not.toHaveBeenCalled(); expect(mockProject.updateProject).toHaveBeenCalledWith({ taskManager: null, }); diff --git a/yarn.lock b/yarn.lock index 2c63c92b..490bd0dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35,38 +35,6 @@ dependencies: json-schema "^0.4.0" -"@amplitude/identify@^1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.10.0.tgz#d62b8b6785c29350c368810475a6fc7b04985210" - integrity sha512-BshMDcZX9qO4mgGBR45HmiHxfcPCDY/eBOE/MTUZBW+y9+N61aKmNY3YJsAUfRPzieDiyfqs8rNm7quVkaNzJQ== - dependencies: - "@amplitude/types" "^1.10.0" - "@amplitude/utils" "^1.10.0" - tslib "^1.9.3" - -"@amplitude/node@^1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@amplitude/node/-/node-1.10.0.tgz#33f84ddf82b31471fce53e6fa60b688d4bc62ee4" - integrity sha512-Jh8w1UpxhonWe0kCALVvqiBE3vo5NYmbNZbZrrI9Lfa/1HbGboZlGdg0I7/WtihbZvEjpfcfTOf8OkmtZh6vsQ== - dependencies: - "@amplitude/identify" "^1.10.0" - "@amplitude/types" "^1.10.0" - "@amplitude/utils" "^1.10.0" - tslib "^1.9.3" - -"@amplitude/types@^1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@amplitude/types/-/types-1.10.0.tgz#dfaf7cc25f533a1e2b0ef0ad675371b396733c0f" - integrity sha512-xN0gnhutztv6kqHaZ2bre18anQV5GDmMXOeipTvI670g2VjNbPfOzMwu1LN4p1NadYq+GqYI223UcZrXR+R4Pw== - -"@amplitude/utils@^1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@amplitude/utils/-/utils-1.10.0.tgz#138b0ba4e5755540a9e4abf426b7a25d045418a9" - integrity sha512-/R8j8IzFH0GYfA6ehQDm5IEzt71gIeMdiYYFIzZp6grERQlgJcwNJMAiza0o2JwwTDIruzqdB3c/vLVjuakp+w== - dependencies: - "@amplitude/types" "^1.10.0" - tslib "^1.9.3" - "@apollo/protobufjs@1.2.4": version "1.2.4" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.4.tgz#d913e7627210ec5efd758ceeb751c776c68ba133" @@ -526,26 +494,19 @@ dependencies: tslib "^2.4.0" -"@hawk.so/nodejs@^3.1.1": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@hawk.so/nodejs/-/nodejs-3.1.2.tgz#b06229f0c8a0d8676412329511f9f2b01e492211" - integrity sha512-FqZtJDEc3G/VdirsEEfA4BodA3OGXCSy2188aPSeaLkLWswaKAnkaJNTGHQL59dtOeSbvipMJVgtoqihHkpGBQ== +"@hawk.so/nodejs@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@hawk.so/nodejs/-/nodejs-3.3.1.tgz#23e304607a64cd3a91e488d481cc968fccab6dba" + integrity sha512-GALpgM/96As5gE3YdwVcMglTc57Dfqez3b2EciKJoq0u174gK/h+8tayEL+/65pqBy7BNni8ptCQWdgw5Zv5yA== dependencies: - "@hawk.so/types" "^0.1.15" + "@hawk.so/types" "^0.5.8" axios "^0.21.1" stack-trace "^0.0.10" -"@hawk.so/types@^0.1.15": - version "0.1.18" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.18.tgz#746537634756825f066182737429d11ea124d5c5" - integrity sha512-SvECLGmLb5t90OSpk3n8DCjJsUoyjrq/Z6Ioil80tVkbMXRdGjaHZpn/0w1gBqtgNWBfW2cSbsQPqmyDj1NsqQ== - dependencies: - "@types/mongodb" "^3.5.34" - -"@hawk.so/types@^0.5.6": - version "0.5.6" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.6.tgz#1fbd06a79de32595936c817ff416471c0767bd5a" - integrity sha512-oPoi0Zf2GZDh0OdEd+imw9VAIJcp9zwtk3jLVBOvXcX+LbTKOt0kwkcblacQpsTFB1ljleVQ15gULnV3qbHCLw== +"@hawk.so/types@^0.5.8": + version "0.5.8" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.8.tgz#4278b489f77b5b0335a04ae8184f87c2112116d0" + integrity sha512-3LebU/fFWFCVBHcj8yAyZqjjam9vYo7diRi8BlMBXJ5yC1fE7M44+Zb+lzudHQnysj+ZcHZyBuA/dEpGhB7vxg== dependencies: bson "^7.0.0" @@ -1405,13 +1366,6 @@ "@types/connect" "*" "@types/node" "*" -"@types/bson@*": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@types/bson/-/bson-4.2.0.tgz#a2f71e933ff54b2c3bf267b67fa221e295a33337" - integrity sha512-ELCPqAdroMdcuxqwMgUpifQyRoTpyYCNr1V9xKyF40VsBobsj+BbWNRvwGchMgBPGqkw655ypkjj2MEF5ywVwg== - dependencies: - bson "*" - "@types/connect@*": version "3.4.35" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" @@ -1634,14 +1588,6 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.0.tgz#e9a9903894405c6a6551f1774df4e64d9804d69c" integrity sha512-fccbsHKqFDXClBZTDLA43zl0+TbxyIwyzIzwwhvoJvhNjOErCdeX2xJbURimv2EbSVUGav001PaCJg4mZxMl4w== -"@types/mongodb@^3.5.34": - version "3.6.20" - resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.6.20.tgz#b7c5c580644f6364002b649af1c06c3c0454e1d2" - integrity sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ== - dependencies: - "@types/bson" "*" - "@types/node" "*" - "@types/morgan@^1.9.10": version "1.9.10" resolved "https://registry.yarnpkg.com/@types/morgan/-/morgan-1.9.10.tgz#725c15d95a5e6150237524cd713bc2d68f9edf1a" @@ -2520,7 +2466,7 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -bson@*, bson@^1.1.4, bson@^6.10.4, bson@^6.7.0, bson@^7.0.0: +bson@^1.1.4, bson@^6.10.4, bson@^6.7.0, bson@^7.0.0: version "6.10.4" resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.4.tgz#d530733bb5bb16fb25c162e01a3344fab332fd2b" integrity sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng== @@ -6662,7 +6608,7 @@ tsconfig@^7.0.0: strip-bom "^3.0.0" strip-json-comments "^2.0.0" -tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==