Skip to content

Commit

Permalink
feat: Add basic support
Browse files Browse the repository at this point in the history
Use gcl-slack through Fastify, as a Fastify-plugin or Cloud Functions.
  • Loading branch information
simenandre committed Aug 29, 2021
1 parent 0e549c1 commit c13c88b
Show file tree
Hide file tree
Showing 16 changed files with 4,901 additions and 16 deletions.
35 changes: 19 additions & 16 deletions README.md
Expand Up @@ -20,6 +20,7 @@

---

[![lifecycle](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://www.tidyverse.org/lifecycle/#experimental)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=flat-square)](http://commitizen.github.io/cz-cli/)
![Release](https://github.com/bjerkio/gcl-slack/workflows/Release/badge.svg)
Expand All @@ -28,10 +29,10 @@
[![codecov](https://codecov.io/gh/bjerkio/gcl-slack/branch/main/graph/badge.svg)](https://codecov.io/gh/bjerkio/gcl-slack)
[![Maintainability](https://api.codeclimate.com/v1/badges/abaf7c9907eccc452518/maintainability)](https://codeclimate.com/github/bjerkio/gcl-slack/maintainability)

**gcl-slack** is built to consume logs in Google Cloud Logger and forward to
Slack. You can use this library to let your team know a something happened in
your app, if an exception is thrown or use the special [slack object] to send
[slack blocks] to remove requests to Slack from your production load.
**gcl-slack** consumes logs from Google Cloud Logger and forwards them to Slack.
Use this library to let your team know something happened in your app, an
exception is thrown or use the special [slack object] to turn structured logs to
well-formatted Slack messages.

[slack object]: #
[slack blocks]: https://api.slack.com/block-kit
Expand Down Expand Up @@ -60,21 +61,23 @@ forward log entries to `gcl-slack`.
[sink]: https://cloud.google.com/logging/docs/export/configure_export_v2
[search query]: https://cloud.google.com/logging/docs/view/advanced-queries

### _Offload Requests_ to Slack API
### Turn structured logs to well-formatted Slack messages

This package is designed to solve two issues, a) forward information from Cloud
Logger and b) offload requests to Slack API.
This package solves two issues, a) forwarding information from Cloud Logger to
Slack and b) offload requests to Slack API / webhooks.

Sometimes we want to be notified on Slack when something happends, let's say a
user is created or a customer subscribed to your service. Since we have a
state-of-the-art logging system listening in on the application outputs we can
output a message or even better a JSON object. This object is dumped to Cloud
Logger, which you create a [sink]s to route log entries to this library. This
library can consume what we call [slack object]s, which is basicly the same as
you would send to either the Slack API or webhook.
Sometimes we want to be notified on Slack when something happens – let's say a
user is created or a customer subscribed to your service. Since out the output
of our application is hooked into Cloud Logging, we can output a message or a
JSON object (structured logging). With a sink, we can route log entites to this
library through Google PubSub.

The result is that your Kubernetes Pod, Cloud Run or Cloud Functions instance
can dump a simple JSON object to the log and your team can see it in a channel.
To create more than just a simple message, you can use [slack object]s, which is
the same as you would send to either the Slack API or webhooks to create
well-formatted slack messages.

Let your Kubernetes Pod, Cloud Run or Cloud Functions focus on core business,
and let distribute your logging to Slack with this library!

[pulumi-callback]:
https://www.pulumi.com/blog/simple-serverless-programming-with-google-cloud-functions-and-pulumi/
Expand Down
25 changes: 25 additions & 0 deletions jest.config.js
@@ -0,0 +1,25 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testPathIgnorePatterns: [
'.*/fixtures.ts',
'.*/__fixtures__/*',
'.*/*.fixtures.ts',
'.*/dist/.*',
'.*dist.*',
],
collectCoverageFrom: ['src/**/{!(fixtures),}.ts'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: -10,
},
},
globals: {
'ts-jest': {
tsconfig: 'tsconfig.jest.json',
},
},
};
47 changes: 47 additions & 0 deletions package.json
@@ -0,0 +1,47 @@
{
"name": "gcl-slack",
"version": "1.0.0",
"private": false,
"repository": "github:bjerkio/gcl-slack",
"license": "Apache-2.0",
"author": "Bjerk AS",
"main": "./dist/cjs/index.js",
"module": "./dist/index.js",
"source": "src/index.ts",
"types": "./dist/index.d.ts",
"files": [
"dist/"
],
"scripts": {
"build": "tsc && tsc -p tsconfig.cjs.json",
"lint": "eslint 'src/**/*.ts' --fix",
"test": "jest --coverage src",
"prepare": "husky install .github/husky"
},
"devDependencies": {
"@bjerk/eslint-config": "^1.0.0",
"@types/jest": "^27.0.1",
"eslint": "^7.32.0",
"husky": "^7.0.2",
"jest": "^27.1.0",
"lint-staged": "^11.1.2",
"nock": "^13.1.3",
"prettier": "^2.3.2",
"ts-jest": "^27.0.5",
"typescript": "^3.9"
},
"dependencies": {
"@slack-wrench/jest-mock-web-client": "^1.3.0",
"@slack/web-api": "^6.4.0",
"axios": "^0.21.1",
"fastify": "^3.20.2",
"pubsub-http-handler": "^2.0.0",
"runtypes": "^6.3.2"
},
"lint-staged": {
"*.ts": [
"prettier --write",
"yarn run eslint"
]
}
}
2 changes: 2 additions & 0 deletions src/__mocks__/@slack/web-api.ts
@@ -0,0 +1,2 @@
import mockWebApi from '@slack-wrench/jest-mock-web-client';
module.exports = mockWebApi(jest);
49 changes: 49 additions & 0 deletions src/__tests__/api.test.ts
@@ -0,0 +1,49 @@
import { MockedWebClient } from '@slack-wrench/jest-mock-web-client';
import { SlackApiMethod } from '../methods/api';
import { createLogEntry } from './fixtures';

describe('api', () => {
const m = new SlackApiMethod({
token: 'a-token',
defaultChannel: 'my-channel',
});

it('must send simple messages to Slack API', async () => {
await m.send(createLogEntry('hello-world'));

expect(
MockedWebClient.mock.instances[0].chat.postMessage,
).toHaveBeenCalledWith({
channel: 'my-channel',
text: 'hello-world',
});
});

it('must send simple JSON Objects to Slack API', async () => {
await m.send(createLogEntry({ message: 'hello world message' }));

expect(
MockedWebClient.mock.instances[0].chat.postMessage,
).toHaveBeenCalledWith({
channel: 'my-channel',
text: 'hello world message',
});
});

it('must send requests with object to Slack API', async () => {
await m.send(
createLogEntry({
slack: {
text: 'hello, world',
},
}),
);

expect(
MockedWebClient.mock.instances[0].chat.postMessage,
).toHaveBeenCalledWith({
channel: 'my-channel',
text: 'hello, world',
});
});
});
24 changes: 24 additions & 0 deletions src/__tests__/fixtures.ts
@@ -0,0 +1,24 @@
/* istanbul ignore file */
import { LogEntry } from '../types';

export function createLogEntry(payload: any): LogEntry {
if (typeof payload === 'string') {
return {
insertId: 'insertId',
logName: 'logName',
receiveTimestamp: 'receiveTimestamp',
severity: 'INFO',
timestamp: 'timestamp',
textPayload: payload,
};
}

return {
insertId: 'insertId',
logName: 'logName',
receiveTimestamp: 'receiveTimestamp',
severity: 'INFO',
timestamp: 'timestamp',
jsonPayload: payload,
};
}
66 changes: 66 additions & 0 deletions src/__tests__/webhook.test.ts
@@ -0,0 +1,66 @@
import nock from 'nock';
import { SlackWebhookMethod } from '../methods/webhook';
import { createLogEntry } from './fixtures';

describe('api', () => {
const m = new SlackWebhookMethod({
url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX',
});
const mock = nock('https://hooks.slack.com/');

it('must send simple messages to webhook', async () => {
const scope = mock
.post('/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', {
text: 'hello-world',
})
.reply(200, 'ok');

await m.send(createLogEntry('hello-world'));

expect(scope.isDone()).toBeTruthy();
});

it('must throw if webhook not replies «ok»', async () => {
const scope = mock
.post('/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', {
text: 'hello no service',
})
.reply(404, 'no_service');

expect(
m.send(createLogEntry('hello no service')),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Could not send request to Slack Webhook: no_service"`,
);
});

it('must send simple JSON Objects to webhook', async () => {
const scope = mock
.post('/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', {
text: 'hello world message',
})
.reply(200, 'ok');

await m.send(createLogEntry({ message: 'hello world message' }));

expect(scope.isDone()).toBeTruthy();
});

it('must send requests with object to webhook', async () => {
const scope = mock
.post('/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', {
text: 'hello, world',
})
.reply(200, 'ok');

await m.send(
createLogEntry({
slack: {
text: 'hello, world',
},
}),
);

expect(scope.isDone()).toBeTruthy();
});
});
24 changes: 24 additions & 0 deletions src/handler.ts
@@ -0,0 +1,24 @@
import { PubSubHandler } from 'pubsub-http-handler';
import { SlackApiMethod } from './methods/api';
import { SlackWebhookMethod } from './methods/webhook';
import { LogEntry, SlackConfig, SlackMethod } from './types';

export function makeHandler(config: SlackConfig): PubSubHandler<LogEntry> {
if (config.type !== 'api' && config.type !== 'webhook') {
throw new Error('Slack config type was neither set to `api` nor `webhook`');
}

let method: SlackMethod;

if (config.type === 'api') {
method = new SlackApiMethod(config.apiOptions);
}

if (config.type === 'webhook') {
method = new SlackWebhookMethod(config.webhookOptions);
}

return async ({ data }) => {
await method.send(data);
};
}
24 changes: 24 additions & 0 deletions src/index.ts
@@ -0,0 +1,24 @@
import { FastifyPluginAsync } from 'fastify';
import * as phh from 'pubsub-http-handler';
import { makeHandler } from './handler';
import { SlackConfig } from './types';
export { makeHandler } from './handler';

export const makePubSubCloudFunctions = (
config: SlackConfig,
): phh.CloudFunctionFun => {
return phh.createPubSubCloudFunctions(makeHandler(config));
};

export const fastifyPlugin: FastifyPluginAsync<SlackConfig> = async (
fastify,
config,
) => {
return phh.pubSubFastifyPlugin(fastify, { handler: makeHandler(config) });
};

export const makePubSubServer = (
config: SlackConfig,
): phh.CreatePubSubHandlerResponse => {
return phh.createPubSubServer(makeHandler(config));
};
25 changes: 25 additions & 0 deletions src/methods/api.ts
@@ -0,0 +1,25 @@
import { WebClient } from '@slack/web-api';
import type { LogEntry, SlackApiOptions, SlackMethod } from '../types';

export class SlackApiMethod implements SlackMethod {
private client: WebClient;

constructor(private readonly config: SlackApiOptions) {
this.client = new WebClient(config.token, config.clientOptions);
}

async send(entry: LogEntry): Promise<void> {
if (entry.jsonPayload?.slack) {
await this.client.chat.postMessage({
channel: this.config.defaultChannel,
...entry.jsonPayload.slack,
});
return;
}

await this.client.chat.postMessage({
channel: this.config.defaultChannel,
text: entry.jsonPayload?.message ?? entry.textPayload,
});
}
}
29 changes: 29 additions & 0 deletions src/methods/webhook.ts
@@ -0,0 +1,29 @@
import axios, { AxiosError } from 'axios';
import type { LogEntry, SlackMethod, SlackWebhookOptions } from '../types';

export type WebhookResponse = 'ok' | string;

export class SlackWebhookMethod implements SlackMethod {
constructor(private readonly config: SlackWebhookOptions) {}

async send(entry: LogEntry): Promise<void> {
if (entry.jsonPayload?.slack) {
await this.sendRequest(entry.jsonPayload.slack);
return;
}

await this.sendRequest({
text: entry.jsonPayload?.message ?? entry.textPayload,
});
}

private async sendRequest(data: unknown): Promise<void> {
await axios.post(this.config.url, data).catch((error: AxiosError) => {
if (error.response) {
throw new Error(
`Could not send request to Slack Webhook: ${error.response.data}`,
);
}
});
}
}

0 comments on commit c13c88b

Please sign in to comment.