Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/webhook-url-mismatch-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@slack/webhook": minor
---

feat: add logger with URL mismatch warning

Both `IncomingWebhook` and `WebhookTrigger` now accept optional `logger` and `logLevel` options. On construction, each class warns if the provided URL appears to be intended for the other class (e.g. a `/triggers/` URL passed to `IncomingWebhook`), helping catch misconfiguration before it causes duplicate deliveries or hangs.
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/webhook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts src/WebhookTrigger.test.ts src/instrument.test.ts src/retry-policies.test.ts"
},
"dependencies": {
"@slack/logger": "^5.0.0",
"@slack/types": "^3.0.0",
"@types/node": ">=20",
"@types/retry": "0.12.5",
Expand Down
40 changes: 40 additions & 0 deletions packages/webhook/src/IncomingWebhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './errors';
import { type FetchFunction, IncomingWebhook } from './IncomingWebhook';
import { getUserAgent } from './instrument';
import { LogLevel } from './logger';
import { rapidRetryPolicy } from './retry-policies';

const url = 'https://hooks.slack.com/services/FAKEWEBHOOK';
Expand Down Expand Up @@ -49,6 +50,45 @@ describe('IncomingWebhook', () => {
await webhook.send('Hello');
assert.ok(fetchCalled);
});

it('should warn when URL contains /triggers/', () => {
const warnings: string[] = [];
const logger = {
debug() {},
info() {},
warn(...msg: string[]) {
warnings.push(msg.join(' '));
},
error() {},
setLevel() {},
getLevel() {
return LogLevel.WARN;
},
setName() {},
};
new IncomingWebhook('https://hooks.slack.com/triggers/T000/abc', { logger });
assert.strictEqual(warnings.length, 1);
assert.ok(warnings[0].includes('WebhookTrigger'));
});

it('should not warn when URL contains /services/', () => {
const warnings: string[] = [];
const logger = {
debug() {},
info() {},
warn(...msg: string[]) {
warnings.push(msg.join(' '));
},
error() {},
setLevel() {},
getLevel() {
return LogLevel.WARN;
},
setName() {},
};
new IncomingWebhook('https://hooks.slack.com/services/T000/B000/abc', { logger });
assert.strictEqual(warnings.length, 0);
});
});

describe('send()', () => {
Expand Down
33 changes: 27 additions & 6 deletions packages/webhook/src/IncomingWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import pRetry, { AbortError } from 'p-retry';

import { IncomingWebhookHTTPError, IncomingWebhookRequestError, SlackWebhookError } from './errors';
import { getUserAgent } from './instrument';
import { getLogger, type Logger, LogLevel } from './logger';
import type { RetryOptions } from './retry-policies';

export interface FetchHeaders {
Expand Down Expand Up @@ -65,6 +66,8 @@ export class IncomingWebhook {
*/
private retryConfig: RetryOptions;

private logger: Logger;

public constructor(
url: string,
defaults: IncomingWebhookDefaultArguments = {
Expand All @@ -75,6 +78,8 @@ export class IncomingWebhook {
throw new Error('Incoming webhook URL is required');
}

this.logger = getLogger('IncomingWebhook', defaults.logLevel ?? LogLevel.INFO, defaults.logger);

this.url = url;
this.fetchFn = defaults.fetch ?? globalThis.fetch;
this.timeout = defaults.timeout ?? 0;
Expand All @@ -83,8 +88,22 @@ export class IncomingWebhook {
'User-Agent': getUserAgent(),
};

if (url.includes('/triggers/')) {
this.logger.warn(
'This URL looks like a webhook trigger (contains "/triggers/"). ' +
'Consider using WebhookTrigger instead of IncomingWebhook.',
);
}

// Remove transport options so they don't leak into payloads
const { fetch: _fetch, timeout: _timeout, retryConfig: _retryConfig, ...messageDefaults } = defaults;
const {
fetch: _fetch,
timeout: _timeout,
retryConfig: _retryConfig,
logger: _logger,
logLevel: _logLevel,
...messageDefaults
} = defaults;
this.defaults = messageDefaults;
}

Expand Down Expand Up @@ -150,15 +169,17 @@ export class IncomingWebhook {
*/

export interface IncomingWebhookDefaultArguments {
username?: string;
channel?: string;
fetch?: FetchFunction;
icon_emoji?: string;
icon_url?: string;
channel?: string;
text?: string;
link_names?: boolean;
fetch?: FetchFunction;
timeout?: number;
logLevel?: LogLevel;
logger?: Logger;
retryConfig?: RetryOptions;
text?: string;
timeout?: number;
username?: string;
}

export interface IncomingWebhookSendArguments extends IncomingWebhookDefaultArguments {
Expand Down
40 changes: 40 additions & 0 deletions packages/webhook/src/WebhookTrigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
WebhookTriggerRequestError,
} from './errors';
import { addAppMetadata } from './instrument';
import { LogLevel } from './logger';
import { rapidRetryPolicy } from './retry-policies';
import { WebhookTrigger } from './WebhookTrigger';

Expand Down Expand Up @@ -45,6 +46,45 @@ describe('WebhookTrigger', () => {
assert.throws(() => new WebhookTrigger(undefined as any), /URL is required/);
assert.throws(() => new WebhookTrigger(''), /URL is required/);
});

it('should warn when URL contains /services/', () => {
const warnings: string[] = [];
const logger = {
debug() {},
info() {},
warn(...msg: string[]) {
warnings.push(msg.join(' '));
},
error() {},
setLevel() {},
getLevel() {
return LogLevel.WARN;
},
setName() {},
};
new WebhookTrigger('https://hooks.slack.com/services/T000/B000/abc', { logger });
assert.strictEqual(warnings.length, 1);
assert.ok(warnings[0].includes('IncomingWebhook'));
});

it('should not warn when URL contains /triggers/', () => {
const warnings: string[] = [];
const logger = {
debug() {},
info() {},
warn(...msg: string[]) {
warnings.push(msg.join(' '));
},
error() {},
setLevel() {},
getLevel() {
return LogLevel.WARN;
},
setName() {},
};
new WebhookTrigger('https://hooks.slack.com/triggers/T000/abc', { logger });
assert.strictEqual(warnings.length, 0);
});
});

describe('send()', () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/webhook/src/WebhookTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import pRetry, { AbortError } from 'p-retry';
import { SlackWebhookError, WebhookTriggerHTTPError, WebhookTriggerRequestError } from './errors';
import type { FetchFunction, FetchResponse } from './IncomingWebhook';
import { getUserAgent } from './instrument';
import { getLogger, type Logger, LogLevel } from './logger';
import type { RetryOptions } from './retry-policies';

/**
Expand Down Expand Up @@ -35,6 +36,8 @@ export class WebhookTrigger {
*/
private retryConfig: RetryOptions;

private logger: Logger;

public constructor(
url: string,
defaults: WebhookTriggerDefaultArguments = {
Expand All @@ -45,13 +48,22 @@ export class WebhookTrigger {
throw new Error('Webhook trigger URL is required');
}

this.logger = getLogger('WebhookTrigger', defaults.logLevel ?? LogLevel.INFO, defaults.logger);

this.url = url;
this.fetchFn = defaults.fetch ?? globalThis.fetch;
this.timeout = defaults.timeout ?? 0;
this.retryConfig = defaults.retryConfig ?? { retries: 0 };
this.headers = {
'User-Agent': getUserAgent(),
};

if (url.includes('/services/')) {
this.logger.warn(
'This URL looks like an incoming webhook (contains "/services/"). ' +
'Consider using IncomingWebhook instead of WebhookTrigger.',
);
}
}

/**
Expand Down Expand Up @@ -112,8 +124,10 @@ export class WebhookTrigger {

export interface WebhookTriggerDefaultArguments {
fetch?: FetchFunction;
timeout?: number;
logLevel?: LogLevel;
logger?: Logger;
retryConfig?: RetryOptions;
timeout?: number;
}

export interface WebhookTriggerSendArguments {
Expand Down
2 changes: 2 additions & 0 deletions packages/webhook/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export {

export { addAppMetadata } from './instrument';

export { Logger, LogLevel } from './logger';

export { default as retryPolicies, RetryOptions } from './retry-policies';

export {
Expand Down
10 changes: 10 additions & 0 deletions packages/webhook/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ConsoleLogger, type Logger, type LogLevel } from '@slack/logger';

export { Logger, LogLevel } from '@slack/logger';

export function getLogger(name: string, level: LogLevel, existingLogger?: Logger): Logger {
const logger: Logger = existingLogger ?? new ConsoleLogger();
logger.setName(name);
logger.setLevel(level);
return logger;
}