1<?php declare(strict_types=1); 2 3/* 4 * This file is part of the Monolog package. 5 * 6 * (c) Jordi Boggiano <j.boggiano@seld.be> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Monolog\Handler\Slack; 13 14use Monolog\Logger; 15use Monolog\Utils; 16use Monolog\Formatter\NormalizerFormatter; 17use Monolog\Formatter\FormatterInterface; 18 19/** 20 * Slack record utility helping to log to Slack webhooks or API. 21 * 22 * @author Greg Kedzierski <greg@gregkedzierski.com> 23 * @author Haralan Dobrev <hkdobrev@gmail.com> 24 * @see https://api.slack.com/incoming-webhooks 25 * @see https://api.slack.com/docs/message-attachments 26 * 27 * @phpstan-import-type FormattedRecord from \Monolog\Handler\AbstractProcessingHandler 28 * @phpstan-import-type Record from \Monolog\Logger 29 */ 30class SlackRecord 31{ 32 public const COLOR_DANGER = 'danger'; 33 34 public const COLOR_WARNING = 'warning'; 35 36 public const COLOR_GOOD = 'good'; 37 38 public const COLOR_DEFAULT = '#e3e4e6'; 39 40 /** 41 * Slack channel (encoded ID or name) 42 * @var string|null 43 */ 44 private $channel; 45 46 /** 47 * Name of a bot 48 * @var string|null 49 */ 50 private $username; 51 52 /** 53 * User icon e.g. 'ghost', 'http://example.com/user.png' 54 * @var string|null 55 */ 56 private $userIcon; 57 58 /** 59 * Whether the message should be added to Slack as attachment (plain text otherwise) 60 * @var bool 61 */ 62 private $useAttachment; 63 64 /** 65 * Whether the the context/extra messages added to Slack as attachments are in a short style 66 * @var bool 67 */ 68 private $useShortAttachment; 69 70 /** 71 * Whether the attachment should include context and extra data 72 * @var bool 73 */ 74 private $includeContextAndExtra; 75 76 /** 77 * Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] 78 * @var string[] 79 */ 80 private $excludeFields; 81 82 /** 83 * @var ?FormatterInterface 84 */ 85 private $formatter; 86 87 /** 88 * @var NormalizerFormatter 89 */ 90 private $normalizerFormatter; 91 92 /** 93 * @param string[] $excludeFields 94 */ 95 public function __construct( 96 ?string $channel = null, 97 ?string $username = null, 98 bool $useAttachment = true, 99 ?string $userIcon = null, 100 bool $useShortAttachment = false, 101 bool $includeContextAndExtra = false, 102 array $excludeFields = array(), 103 FormatterInterface $formatter = null 104 ) { 105 $this 106 ->setChannel($channel) 107 ->setUsername($username) 108 ->useAttachment($useAttachment) 109 ->setUserIcon($userIcon) 110 ->useShortAttachment($useShortAttachment) 111 ->includeContextAndExtra($includeContextAndExtra) 112 ->excludeFields($excludeFields) 113 ->setFormatter($formatter); 114 115 if ($this->includeContextAndExtra) { 116 $this->normalizerFormatter = new NormalizerFormatter(); 117 } 118 } 119 120 /** 121 * Returns required data in format that Slack 122 * is expecting. 123 * 124 * @phpstan-param FormattedRecord $record 125 * @phpstan-return mixed[] 126 */ 127 public function getSlackData(array $record): array 128 { 129 $dataArray = array(); 130 $record = $this->removeExcludedFields($record); 131 132 if ($this->username) { 133 $dataArray['username'] = $this->username; 134 } 135 136 if ($this->channel) { 137 $dataArray['channel'] = $this->channel; 138 } 139 140 if ($this->formatter && !$this->useAttachment) { 141 /** @phpstan-ignore-next-line */ 142 $message = $this->formatter->format($record); 143 } else { 144 $message = $record['message']; 145 } 146 147 if ($this->useAttachment) { 148 $attachment = array( 149 'fallback' => $message, 150 'text' => $message, 151 'color' => $this->getAttachmentColor($record['level']), 152 'fields' => array(), 153 'mrkdwn_in' => array('fields'), 154 'ts' => $record['datetime']->getTimestamp(), 155 'footer' => $this->username, 156 'footer_icon' => $this->userIcon, 157 ); 158 159 if ($this->useShortAttachment) { 160 $attachment['title'] = $record['level_name']; 161 } else { 162 $attachment['title'] = 'Message'; 163 $attachment['fields'][] = $this->generateAttachmentField('Level', $record['level_name']); 164 } 165 166 if ($this->includeContextAndExtra) { 167 foreach (array('extra', 'context') as $key) { 168 if (empty($record[$key])) { 169 continue; 170 } 171 172 if ($this->useShortAttachment) { 173 $attachment['fields'][] = $this->generateAttachmentField( 174 (string) $key, 175 $record[$key] 176 ); 177 } else { 178 // Add all extra fields as individual fields in attachment 179 $attachment['fields'] = array_merge( 180 $attachment['fields'], 181 $this->generateAttachmentFields($record[$key]) 182 ); 183 } 184 } 185 } 186 187 $dataArray['attachments'] = array($attachment); 188 } else { 189 $dataArray['text'] = $message; 190 } 191 192 if ($this->userIcon) { 193 if (filter_var($this->userIcon, FILTER_VALIDATE_URL)) { 194 $dataArray['icon_url'] = $this->userIcon; 195 } else { 196 $dataArray['icon_emoji'] = ":{$this->userIcon}:"; 197 } 198 } 199 200 return $dataArray; 201 } 202 203 /** 204 * Returns a Slack message attachment color associated with 205 * provided level. 206 */ 207 public function getAttachmentColor(int $level): string 208 { 209 switch (true) { 210 case $level >= Logger::ERROR: 211 return static::COLOR_DANGER; 212 case $level >= Logger::WARNING: 213 return static::COLOR_WARNING; 214 case $level >= Logger::INFO: 215 return static::COLOR_GOOD; 216 default: 217 return static::COLOR_DEFAULT; 218 } 219 } 220 221 /** 222 * Stringifies an array of key/value pairs to be used in attachment fields 223 * 224 * @param mixed[] $fields 225 */ 226 public function stringify(array $fields): string 227 { 228 /** @var Record $fields */ 229 $normalized = $this->normalizerFormatter->format($fields); 230 231 $hasSecondDimension = count(array_filter($normalized, 'is_array')); 232 $hasNonNumericKeys = !count(array_filter(array_keys($normalized), 'is_numeric')); 233 234 return $hasSecondDimension || $hasNonNumericKeys 235 ? Utils::jsonEncode($normalized, JSON_PRETTY_PRINT|Utils::DEFAULT_JSON_FLAGS) 236 : Utils::jsonEncode($normalized, Utils::DEFAULT_JSON_FLAGS); 237 } 238 239 /** 240 * Channel used by the bot when posting 241 * 242 * @param ?string $channel 243 * 244 * @return static 245 */ 246 public function setChannel(?string $channel = null): self 247 { 248 $this->channel = $channel; 249 250 return $this; 251 } 252 253 /** 254 * Username used by the bot when posting 255 * 256 * @param ?string $username 257 * 258 * @return static 259 */ 260 public function setUsername(?string $username = null): self 261 { 262 $this->username = $username; 263 264 return $this; 265 } 266 267 public function useAttachment(bool $useAttachment = true): self 268 { 269 $this->useAttachment = $useAttachment; 270 271 return $this; 272 } 273 274 public function setUserIcon(?string $userIcon = null): self 275 { 276 $this->userIcon = $userIcon; 277 278 if (\is_string($userIcon)) { 279 $this->userIcon = trim($userIcon, ':'); 280 } 281 282 return $this; 283 } 284 285 public function useShortAttachment(bool $useShortAttachment = false): self 286 { 287 $this->useShortAttachment = $useShortAttachment; 288 289 return $this; 290 } 291 292 public function includeContextAndExtra(bool $includeContextAndExtra = false): self 293 { 294 $this->includeContextAndExtra = $includeContextAndExtra; 295 296 if ($this->includeContextAndExtra) { 297 $this->normalizerFormatter = new NormalizerFormatter(); 298 } 299 300 return $this; 301 } 302 303 /** 304 * @param string[] $excludeFields 305 */ 306 public function excludeFields(array $excludeFields = []): self 307 { 308 $this->excludeFields = $excludeFields; 309 310 return $this; 311 } 312 313 public function setFormatter(?FormatterInterface $formatter = null): self 314 { 315 $this->formatter = $formatter; 316 317 return $this; 318 } 319 320 /** 321 * Generates attachment field 322 * 323 * @param string|mixed[] $value 324 * 325 * @return array{title: string, value: string, short: false} 326 */ 327 private function generateAttachmentField(string $title, $value): array 328 { 329 $value = is_array($value) 330 ? sprintf('```%s```', substr($this->stringify($value), 0, 1990)) 331 : $value; 332 333 return array( 334 'title' => ucfirst($title), 335 'value' => $value, 336 'short' => false, 337 ); 338 } 339 340 /** 341 * Generates a collection of attachment fields from array 342 * 343 * @param mixed[] $data 344 * 345 * @return array<array{title: string, value: string, short: false}> 346 */ 347 private function generateAttachmentFields(array $data): array 348 { 349 /** @var Record $data */ 350 $normalized = $this->normalizerFormatter->format($data); 351 352 $fields = array(); 353 foreach ($normalized as $key => $value) { 354 $fields[] = $this->generateAttachmentField((string) $key, $value); 355 } 356 357 return $fields; 358 } 359 360 /** 361 * Get a copy of record with fields excluded according to $this->excludeFields 362 * 363 * @phpstan-param FormattedRecord $record 364 * 365 * @return mixed[] 366 */ 367 private function removeExcludedFields(array $record): array 368 { 369 foreach ($this->excludeFields as $field) { 370 $keys = explode('.', $field); 371 $node = &$record; 372 $lastKey = end($keys); 373 foreach ($keys as $key) { 374 if (!isset($node[$key])) { 375 break; 376 } 377 if ($lastKey === $key) { 378 unset($node[$key]); 379 break; 380 } 381 $node = &$node[$key]; 382 } 383 } 384 385 return $record; 386 } 387} 388