Skip to content

Commit bc50c1a

Browse files
feat: add Slack and Telegram adapters with adapter-owned routing (#16)
* docs: add design spec for Slack and Telegram adapters Covers adapter-owned routing (Approach A), message formatting, rate limits, breaking changes to RoutingConfig/FormattedAlert, and migration path from global routing to per-adapter config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add implementation plan for Slack and Telegram adapters Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor!: remove global RoutingConfig in favor of adapter-owned routing BREAKING CHANGE: RoutingConfig, Router, and webhookUrl/pings on FormattedAlert are removed. Routing is now configured per-adapter via channels/tags/mentions constructor options. DiscordAdapter now accepts channels, tags, and mentions directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add SlackAdapter with Block Kit formatting and routing Supports per-level channel routing via webhook URLs, tag-based routing, mentions, and 429 retry with Retry-After header. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add TelegramAdapter with topic routing and HTML formatting Supports per-level forum topic routing, tag-based topic routing, @username mentions, and 429 retry with body-based retry_after. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: export SlackAdapter and TelegramAdapter from package entry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review feedback - Slack formatter: context block now appears in all phases, not just onset - Slack formatter: field format changed to inline '*key:* value' - Telegram formatter: info emoji changed from green to blue circle per design spec - Telegram formatter: footer moved outside switch to reduce duplication - Telegram adapter: mentions use double newline separator for readability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix biome formatting (tabs→spaces, import ordering, line width) CI was failing because new files used tabs instead of spaces (biome.json specifies indentStyle: "space", indentWidth: 2). Also fixes multi-line function signatures and import ordering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Codex review — sanitization, truncation, mentions safety - Telegram formatter: truncate stack traces before wrapping in <code> tags so HTML stays balanced when hitting the 4096 char limit. Drop code blocks entirely if message is still over limit. - Telegram adapter: re-enforce 4096 limit after prepending mentions to prevent sendMessage 400 errors on near-limit alerts. - Slack formatter: sanitize alert content in mrkdwn blocks to prevent mention injection (<@u123>, <!channel>, @everyone etc), matching the sanitization already present in the Discord formatter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add changeset for Slack/Telegram adapters (major) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 61c5523 commit bc50c1a

22 files changed

Lines changed: 3400 additions & 165 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@iqai/alert-logger": major
3+
---
4+
5+
Add Slack and Telegram adapters with adapter-owned routing
6+
7+
**Breaking changes:**
8+
- Removed `RoutingConfig` type, `Router` class, and `routing` option from `AlertLoggerConfig`
9+
- Removed `webhookUrl` and `pings` from `FormattedAlert`
10+
- Removed `pings` from `EnvironmentConfig`
11+
- Routing is now configured per-adapter via `channels`, `tags`, and `mentions` constructor options
12+
13+
**Migration:** Move `routing.channels`, `routing.tags`, and `routing.pings` into your adapter constructor:
14+
15+
```ts
16+
// Before
17+
AlertLogger.init({
18+
adapters: [new DiscordAdapter({ webhookUrl: '...' })],
19+
routing: {
20+
channels: { critical: '...' },
21+
pings: { critical: ['<@&role>'] },
22+
},
23+
})
24+
25+
// After
26+
AlertLogger.init({
27+
adapters: [
28+
new DiscordAdapter({
29+
webhookUrl: '...',
30+
channels: { critical: '...' },
31+
mentions: { critical: ['<@&role>'] },
32+
}),
33+
],
34+
})
35+
```
36+
37+
**New features:**
38+
- `SlackAdapter` — Incoming Webhooks with Block Kit formatting, per-level channel routing, mention support, mrkdwn sanitization
39+
- `TelegramAdapter` — Bot API with HTML formatting, per-level forum topic routing, tag-to-topic mapping, @username mentions, safe HTML truncation
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Slack & Telegram Adapters Design
2+
3+
## Goal
4+
5+
Add Slack and Telegram adapters to `@iqai/alert-logger`, following the same patterns as the existing Discord adapter. Each adapter owns its own routing (per-level channels/topics, per-tag overrides, mentions).
6+
7+
## Breaking Changes
8+
9+
This design moves routing from the global `AlertLoggerConfig.routing` into individual adapter constructors. The global `RoutingConfig` type, the `Router` class, and the `webhookUrl`/`pings` fields on `FormattedAlert` are removed.
10+
11+
**Migration path:** Move `routing.channels`, `routing.tags`, and `routing.pings` into `DiscordAdapterOptions`.
12+
13+
Before:
14+
```ts
15+
AlertLogger.init({
16+
adapters: [new DiscordAdapter({ webhookUrl: '...' })],
17+
routing: {
18+
channels: { critical: 'https://discord.com/.../critical' },
19+
pings: { critical: ['<@&role>'] },
20+
},
21+
})
22+
```
23+
24+
After:
25+
```ts
26+
AlertLogger.init({
27+
adapters: [
28+
new DiscordAdapter({
29+
webhookUrl: '...',
30+
channels: { critical: 'https://discord.com/.../critical' },
31+
mentions: { critical: ['<@&role>'] },
32+
}),
33+
],
34+
})
35+
```
36+
37+
## Adapter Configs
38+
39+
### SlackAdapter
40+
41+
```ts
42+
interface SlackAdapterOptions {
43+
/** Default Incoming Webhook URL */
44+
webhookUrl: string
45+
/** Override webhook URL per alert level */
46+
channels?: Partial<Record<AlertLevel, string>>
47+
/** Override webhook URL per tag */
48+
tags?: Record<string, string>
49+
/** Slack user/group mentions per level, e.g. ["<@U0123>", "<!subteam^S456>"] */
50+
mentions?: Partial<Record<AlertLevel, string[]>>
51+
}
52+
```
53+
54+
- Uses Slack Incoming Webhooks (no bot token, no OAuth).
55+
- `channels` and `tags` values are webhook URLs (each Slack webhook maps to one channel).
56+
- Rate limit: 1 request/sec per webhook.
57+
58+
### TelegramAdapter
59+
60+
```ts
61+
interface TelegramAdapterOptions {
62+
/** Telegram Bot API token */
63+
botToken: string
64+
/** Target chat ID (group or channel) */
65+
chatId: string
66+
/** Map alert level to a forum topic (message_thread_id) */
67+
topics?: Partial<Record<AlertLevel, number>>
68+
/** Map tag to a forum topic */
69+
tags?: Record<string, number>
70+
/** Telegram @username mentions per level */
71+
mentions?: Partial<Record<AlertLevel, string[]>>
72+
}
73+
```
74+
75+
- Uses the Telegram Bot HTTP API (`sendMessage` endpoint).
76+
- Single group chat with forum topics for per-level routing.
77+
- Rate limit: 20 messages/60s per chat.
78+
79+
### DiscordAdapter (updated)
80+
81+
```ts
82+
interface DiscordAdapterOptions {
83+
/** Default webhook URL */
84+
webhookUrl: string
85+
/** Override webhook URL per alert level */
86+
channels?: Partial<Record<AlertLevel, string>>
87+
/** Override webhook URL per tag */
88+
tags?: Record<string, string>
89+
/** Discord user/role mentions per level, e.g. ["<@123>", "<@&456>"] */
90+
mentions?: Partial<Record<AlertLevel, string[]>>
91+
}
92+
```
93+
94+
- Gains `channels`, `tags`, `mentions` (previously in global `routing` config).
95+
- Existing `webhookUrl` remains the default destination.
96+
97+
## Internal Routing
98+
99+
Each adapter implements a private `resolve(level, tags?)` method that returns the destination + mentions:
100+
101+
- **Discord/Slack:** Returns `{ url: string; mentions: string[] }` — checks tags first, then level, then falls back to the default webhook URL.
102+
- **Telegram:** Returns `{ topicId?: number; mentions: string[] }` — checks tags first, then level. No topic = posts to general chat.
103+
104+
This replaces the current `Router` class and the `webhookUrl`/`pings` fields on `FormattedAlert`.
105+
106+
## Message Formatting
107+
108+
Each adapter has its own `formatter.ts` that handles the 4 aggregation phases (onset, ramp, sustained, resolution).
109+
110+
### Slack Formatter
111+
112+
Uses Block Kit with attachments for color coding:
113+
114+
- **Color bar:** `attachment.color` hex — blue `#3498db` (info), yellow `#f39c12` (warning), red `#e74c3c` (critical), green `#2ecc71` (resolution).
115+
- **Title:** Header block — `[PROD] [CRITICAL] Alert title`.
116+
- **Body:** Section block with `mrkdwn` — alert message, stack traces in triple-backtick code blocks.
117+
- **Fields:** Section fields as `mrkdwn` key/value pairs with `inline: true` equivalent (short fields).
118+
- **Footer:** Context block — service name + timestamp.
119+
- **Mentions:** Plain text block above the attachment (like Discord's `content` field).
120+
121+
Phase-specific formatting mirrors the Discord formatter (onset shows full detail, ramp/sustained show counts, resolution shows totals).
122+
123+
### Telegram Formatter
124+
125+
Uses HTML parse mode (`parse_mode: "HTML"`):
126+
127+
- **Severity indicator:** Emoji prefix — blue circle (info), warning triangle (warning), red circle (critical), green checkmark (resolution).
128+
- **Title:** `<b>[PROD] [CRITICAL] Alert title</b>`.
129+
- **Body:** Alert message as plain text. Stack traces in `<code>` blocks.
130+
- **Fields:** Key-value list — `<b>key:</b> value`.
131+
- **Footer:** `<i>Service: name | timestamp</i>`.
132+
- **Mentions:** `@username` inline in the message.
133+
- **Limit:** 4096 characters per message — truncate with ellipsis.
134+
135+
## Rate Limits & Retry
136+
137+
| Adapter | maxPerWindow | windowMs | Retry Strategy |
138+
|----------|-------------|----------|---------------------------------------------|
139+
| Discord | 30 | 60000 | Retry on 429, `Retry-After` header (secs) |
140+
| Slack | 1 | 1000 | Retry on 429, `Retry-After` header (secs) |
141+
| Telegram | 20 | 60000 | Retry on 429, `retry_after` in JSON body |
142+
143+
All three retry up to 2 times on 429 responses, reading the service-specific retry-after value.
144+
145+
## Core Type Changes
146+
147+
### FormattedAlert
148+
149+
Remove `webhookUrl` and `pings`:
150+
151+
```ts
152+
interface FormattedAlert extends Alert {
153+
aggregation: AggregationMeta
154+
environmentBadge: string
155+
// webhookUrl and pings removed — adapters resolve these internally
156+
}
157+
```
158+
159+
### AlertLoggerConfig
160+
161+
Remove `routing`:
162+
163+
```ts
164+
interface AlertLoggerConfig {
165+
adapters: AlertAdapter[]
166+
serviceName?: string
167+
environment?: string
168+
aggregation?: Partial<AggregationConfig>
169+
// routing removed — each adapter owns its routing
170+
environments?: Record<string, EnvironmentConfig>
171+
queue?: Partial<QueueConfig>
172+
health?: Partial<HealthPolicy>
173+
fingerprint?: Partial<FingerprintConfig>
174+
}
175+
```
176+
177+
### EnvironmentConfig
178+
179+
Remove `pings` field (was per-environment ping overrides — now handled by adapter config):
180+
181+
```ts
182+
interface EnvironmentConfig {
183+
levels?: AlertLevel[]
184+
aggregation?: Partial<AggregationConfig>
185+
// pings removed
186+
}
187+
```
188+
189+
### Router class
190+
191+
Removed entirely. Each adapter resolves destinations internally.
192+
193+
### ResolvedConfig
194+
195+
Remove `routing` and `pings` fields.
196+
197+
## File Structure
198+
199+
```
200+
src/adapters/
201+
console/
202+
console-adapter.ts (unchanged)
203+
discord/
204+
discord-adapter.ts (add routing, remove webhookUrl dependency)
205+
discord-adapter.test.ts (update tests)
206+
formatter.ts (unchanged)
207+
formatter.test.ts (unchanged)
208+
slack/
209+
slack-adapter.ts (new)
210+
slack-adapter.test.ts (new)
211+
formatter.ts (new)
212+
formatter.test.ts (new)
213+
telegram/
214+
telegram-adapter.ts (new)
215+
telegram-adapter.test.ts (new)
216+
formatter.ts (new)
217+
formatter.test.ts (new)
218+
```
219+
220+
## Exports
221+
222+
Add to `src/index.ts`:
223+
224+
```ts
225+
export { SlackAdapter } from './adapters/slack/slack-adapter.js'
226+
export type { SlackAdapterOptions } from './adapters/slack/slack-adapter.js'
227+
export { TelegramAdapter } from './adapters/telegram/telegram-adapter.js'
228+
export type { TelegramAdapterOptions } from './adapters/telegram/telegram-adapter.js'
229+
```
230+
231+
No changes to `tsup.config.ts` — adapters are part of the main entry point.
232+
233+
## Testing
234+
235+
Same pattern as `discord-adapter.test.ts`:
236+
237+
- Mock `fetch` globally with `vi.stubGlobal`.
238+
- Test `send()` posts correct payload to correct URL/endpoint.
239+
- Test routing: level-based, tag-based, default fallback.
240+
- Test mentions appear in correct location.
241+
- Test 429 retry logic with service-specific retry-after parsing.
242+
- Test non-429 error throws.
243+
- Test `rateLimits()` returns expected values.
244+
- Formatter tests cover all 4 phases, truncation, and sanitization.
245+
246+
## Usage Example
247+
248+
```ts
249+
import { AlertLogger, DiscordAdapter, SlackAdapter, TelegramAdapter } from '@iqai/alert-logger'
250+
251+
const logger = AlertLogger.init({
252+
serviceName: 'my-api',
253+
adapters: [
254+
new DiscordAdapter({
255+
webhookUrl: 'https://discord.com/api/webhooks/default',
256+
channels: { critical: 'https://discord.com/api/webhooks/critical' },
257+
mentions: { critical: ['<@&oncall-role>'] },
258+
}),
259+
new SlackAdapter({
260+
webhookUrl: 'https://hooks.slack.com/services/T.../B.../default',
261+
channels: { critical: 'https://hooks.slack.com/services/T.../B.../critical' },
262+
mentions: { critical: ['<@U0123ONCALL>'] },
263+
}),
264+
new TelegramAdapter({
265+
botToken: '123456:ABC-DEF...',
266+
chatId: '-1001234567890',
267+
topics: { critical: 42, warning: 43, info: 44 },
268+
mentions: { critical: ['@oncall_dev'] },
269+
}),
270+
],
271+
})
272+
273+
logger.error('Database connection lost', new Error('ECONNREFUSED'))
274+
```

0 commit comments

Comments
 (0)