Skip to content

Commit 1e74014

Browse files
committed
Add MS Teams webhook handler
1 parent 0d529a7 commit 1e74014

File tree

7 files changed

+835
-7
lines changed

7 files changed

+835
-7
lines changed

doc/02-handlers-formatters-processors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
- [_PushoverHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/PushoverHandler.php): Sends mobile notifications via the [Pushover](https://www.pushover.net/) API.
3434
- [_SlackWebhookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SlackWebhookHandler.php): Logs records to a [Slack](https://www.slack.com/) account using Slack Webhooks.
3535
- [_SlackHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SlackHandler.php): Logs records to a [Slack](https://www.slack.com/) account using the Slack API (complex setup).
36+
- [_TeamsWebhookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/TeamsWebhookHandler.php): Logs records to a [MS Teams](https://www.microsoft.com/microsoft-teams) account using MS Teams Webhooks.
3637
- [_SendGridHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SendGridHandler.php): Sends emails via the SendGrid API.
3738
- [_MandrillHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/MandrillHandler.php): Sends emails via the [`Mandrill API`](https://mandrillapp.com/api/docs/) using a [`Swift_Message`](http://swiftmailer.org/) instance.
3839
- [_FleepHookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FleepHookHandler.php): Logs records to a [Fleep](https://fleep.io/) conversation using Webhooks.

src/Monolog/Handler/Slack/SlackRecord.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class SlackRecord
5656
private bool $useAttachment;
5757

5858
/**
59-
* Whether the the context/extra messages added to Slack as attachments are in a short style
59+
* Whether the context/extra messages added to Slack as attachments are in a short style
6060
*/
6161
private bool $useShortAttachment;
6262

@@ -97,10 +97,6 @@ public function __construct(
9797
->includeContextAndExtra($includeContextAndExtra)
9898
->excludeFields($excludeFields)
9999
->setFormatter($formatter);
100-
101-
if ($this->includeContextAndExtra) {
102-
$this->normalizerFormatter = new NormalizerFormatter();
103-
}
104100
}
105101

106102
/**

src/Monolog/Handler/SlackWebhookHandler.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ class SlackWebhookHandler extends AbstractProcessingHandler
4343
* @param string|null $username Name of a bot
4444
* @param bool $useAttachment Whether the message should be added to Slack as attachment (plain text otherwise)
4545
* @param string|null $iconEmoji The emoji name to use (or null)
46-
* @param bool $useShortAttachment Whether the the context/extra messages added to Slack as attachments are in a short style
46+
* @param bool $useShortAttachment Whether the context/extra messages added to Slack as attachments are in a short style
4747
* @param bool $includeContextAndExtra Whether the attachment should include context and extra data
48-
* @param string[] $excludeFields Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2']
48+
* @param string[] $excludeFields Dot separated list of fields to exclude from Slack message. E.g. ['context.field1', 'extra.field2']
4949
*
5050
* @throws MissingExtensionException If the curl extension is missing
5151
*/
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of the Monolog package.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Monolog\Handler\Teams;
13+
14+
use Monolog\Level;
15+
use Monolog\Utils;
16+
use Monolog\Formatter\NormalizerFormatter;
17+
use Monolog\Formatter\FormatterInterface;
18+
use Monolog\LogRecord;
19+
20+
/**
21+
* MS Teams record utility helping to log to MS Teams webhooks.
22+
*
23+
* @author Sébastien Alfaiate <[email protected]>
24+
* @see https://learn.microsoft.com/adaptive-cards/authoring-cards/getting-started
25+
*/
26+
class TeamsRecord
27+
{
28+
public const COLOR_ATTENTION = 'attention';
29+
30+
public const COLOR_WARNING = 'warning';
31+
32+
public const COLOR_GOOD = 'good';
33+
34+
public const COLOR_DEFAULT = 'default';
35+
36+
/**
37+
* Whether the card should include context and extra data
38+
*/
39+
private bool $includeContextAndExtra;
40+
41+
/**
42+
* Dot separated list of fields to exclude from MS Teams message. E.g. ['context.field1', 'extra.field2']
43+
* @var string[]
44+
*/
45+
private array $excludeFields;
46+
47+
/**
48+
* Dot separated list of fields to display with a toggle button in MS Teams message. E.g. ['context.field1', 'extra.field2']
49+
* @var string[]
50+
*/
51+
private array $toggleFields;
52+
53+
private FormatterInterface|null $formatter;
54+
55+
private NormalizerFormatter $normalizerFormatter;
56+
57+
/**
58+
* @param string[] $excludeFields
59+
* @param string[] $toggleFields
60+
*/
61+
public function __construct(
62+
bool $includeContextAndExtra = false,
63+
array $excludeFields = [],
64+
array $toggleFields = [],
65+
FormatterInterface|null $formatter = null
66+
) {
67+
$this
68+
->includeContextAndExtra($includeContextAndExtra)
69+
->excludeFields($excludeFields)
70+
->toggleFields($toggleFields)
71+
->setFormatter($formatter);
72+
}
73+
74+
/**
75+
* Returns required data in format that MS Teams is expecting.
76+
*
77+
* @phpstan-return mixed[]
78+
*/
79+
public function getAdaptiveCardPayload(LogRecord $record): array
80+
{
81+
if ($this->formatter !== null) {
82+
$message = $this->formatter->format($record);
83+
} else {
84+
$message = $record->message;
85+
}
86+
87+
$recordData = $this->removeExcludedFields($record);
88+
89+
$facts = $toggles = [];
90+
91+
$facts[] = $this->generateFactField('Level', $recordData['level_name']);
92+
93+
if ($this->includeContextAndExtra) {
94+
foreach (['extra', 'context'] as $key) {
95+
if (!isset($recordData[$key]) || \count($recordData[$key]) === 0) {
96+
continue;
97+
}
98+
99+
$data = $this->generateContextAndExtraFields($recordData[$key], $key);
100+
101+
$facts = array_merge($facts, $data['facts']);
102+
$toggles = array_merge($toggles, $data['toggles']);
103+
}
104+
}
105+
106+
return [
107+
'type' => 'message',
108+
'attachments' => [
109+
[
110+
'contentType' => 'application/vnd.microsoft.card.adaptive',
111+
'content' => [
112+
'$schema' => 'http://adaptivecards.io/schemas/adaptive-card.json',
113+
'type' => 'AdaptiveCard',
114+
'version' => '1.5',
115+
'body' => [
116+
// Card Header
117+
[
118+
'type' => 'Container',
119+
'style' => $this->getContainerStyle($record->level),
120+
'items' => [
121+
[
122+
'type' => 'TextBlock',
123+
'text' => $message,
124+
'weight' => 'Bolder',
125+
'size' => 'Medium',
126+
'wrap' => true,
127+
],
128+
],
129+
],
130+
// Context and Extra
131+
[
132+
'type' => 'Container',
133+
'spacing' => 'Medium',
134+
'items' => [
135+
[
136+
'type' => 'FactSet',
137+
'facts' => $facts,
138+
],
139+
],
140+
]
141+
],
142+
// Toggles
143+
'actions' => $toggles,
144+
],
145+
],
146+
],
147+
];
148+
}
149+
150+
/**
151+
* Returns MS Teams container style associated with provided level.
152+
*/
153+
public function getContainerStyle(Level $level): string
154+
{
155+
return match ($level) {
156+
Level::Error, Level::Critical, Level::Alert, Level::Emergency => static::COLOR_ATTENTION,
157+
Level::Warning => static::COLOR_WARNING,
158+
Level::Info, Level::Notice => static::COLOR_GOOD,
159+
Level::Debug => static::COLOR_DEFAULT
160+
};
161+
}
162+
163+
/**
164+
* Stringifies an array of key/value pairs to be used in fact fields
165+
*
166+
* @param mixed[] $fields
167+
*/
168+
public function stringify(array $fields): string
169+
{
170+
/** @var array<array<mixed>|bool|float|int|string|null> $normalized */
171+
$normalized = $this->normalizerFormatter->normalizeValue($fields);
172+
173+
$hasSecondDimension = \count(array_filter($normalized, 'is_array')) > 0;
174+
$hasOnlyNonNumericKeys = \count(array_filter(array_keys($normalized), 'is_numeric')) === 0;
175+
176+
return $hasSecondDimension || $hasOnlyNonNumericKeys
177+
? Utils::jsonEncode($normalized, JSON_PRETTY_PRINT|Utils::DEFAULT_JSON_FLAGS)
178+
: Utils::jsonEncode($normalized, Utils::DEFAULT_JSON_FLAGS);
179+
}
180+
181+
/**
182+
* @return $this
183+
*/
184+
public function includeContextAndExtra(bool $includeContextAndExtra = false): self
185+
{
186+
$this->includeContextAndExtra = $includeContextAndExtra;
187+
188+
if ($this->includeContextAndExtra) {
189+
$this->normalizerFormatter = new NormalizerFormatter();
190+
}
191+
192+
return $this;
193+
}
194+
195+
/**
196+
* @param string[] $excludeFields
197+
* @return $this
198+
*/
199+
public function excludeFields(array $excludeFields = []): self
200+
{
201+
$this->excludeFields = $excludeFields;
202+
203+
return $this;
204+
}
205+
206+
/**
207+
* @param string[] $toggleFields
208+
* @return $this
209+
*/
210+
public function toggleFields(array $toggleFields = []): self
211+
{
212+
$this->toggleFields = $toggleFields;
213+
214+
return $this;
215+
}
216+
217+
/**
218+
* @return $this
219+
*/
220+
public function setFormatter(?FormatterInterface $formatter = null): self
221+
{
222+
$this->formatter = $formatter;
223+
224+
return $this;
225+
}
226+
227+
/**
228+
* Generates fact field
229+
*
230+
* @param string|mixed[] $value
231+
*
232+
* @return array{title: string, value: string}
233+
*/
234+
private function generateFactField(string $title, $value): array
235+
{
236+
$value = \is_array($value)
237+
? substr($this->stringify($value), 0, 1990)
238+
: $value;
239+
240+
return [
241+
'title' => ucfirst($title),
242+
'value' => $value,
243+
];
244+
}
245+
246+
/**
247+
* Generates fact field
248+
*
249+
* @param string|mixed[] $value
250+
*
251+
* @return array{type: string, title: string, card: array{type: string, body: array<array{type: string, text: string, wrap: bool}>}}
252+
*/
253+
private function generateToggleField(string $title, $value): array
254+
{
255+
$value = \is_array($value)
256+
? substr($this->stringify($value), 0, 19990)
257+
: $value;
258+
259+
return [
260+
'type' => 'Action.ShowCard',
261+
'title' => ucfirst($title),
262+
'card' => [
263+
'type' => 'AdaptiveCard',
264+
'body' => [
265+
[
266+
'type' => 'TextBlock',
267+
'text' => $value,
268+
'wrap' => true,
269+
],
270+
],
271+
],
272+
];
273+
}
274+
275+
/**
276+
* Generates a collection of fact fields from array
277+
*
278+
* @param mixed[] $data
279+
*
280+
* @return array{facts: array<array{title: string, value: string}>, toggles: array<array{type: string, title: string, card: array{type: string, body: array<array{type: string, text: string, wrap: bool}>}}>}
281+
*/
282+
private function generateContextAndExtraFields(array $data, string $type): array
283+
{
284+
/** @var array<array<mixed>|string> $normalized */
285+
$normalized = $this->normalizerFormatter->normalizeValue($data);
286+
287+
$fields = [
288+
'facts' => [],
289+
'toggles' => [],
290+
];
291+
292+
foreach ($normalized as $key => $value) {
293+
if (in_array($type.'.'.$key, $this->toggleFields, true)) {
294+
$fields['toggles'][] = $this->generateToggleField((string) $key, $value);
295+
} else {
296+
$fields['facts'][] = $this->generateFactField((string) $key, $value);
297+
}
298+
}
299+
300+
return $fields;
301+
}
302+
303+
/**
304+
* Get a copy of record with fields excluded according to $this->excludeFields
305+
*
306+
* @return mixed[]
307+
*/
308+
private function removeExcludedFields(LogRecord $record): array
309+
{
310+
$recordData = $record->toArray();
311+
foreach ($this->excludeFields as $field) {
312+
$keys = explode('.', $field);
313+
$node = &$recordData;
314+
$lastKey = end($keys);
315+
foreach ($keys as $key) {
316+
if (!isset($node[$key])) {
317+
break;
318+
}
319+
if ($lastKey === $key) {
320+
unset($node[$key]);
321+
break;
322+
}
323+
$node = &$node[$key];
324+
}
325+
}
326+
327+
return $recordData;
328+
}
329+
}

0 commit comments

Comments
 (0)