├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── app.py ├── docker_monitor.py ├── line_processor.py ├── load_config.py └── notifier.py ├── config_example.yaml ├── config_template.yaml ├── docker-compose.yaml ├── entrypoint.sh ├── images ├── abs_download.png ├── abs_login.png ├── abs_template_examples.png ├── abs_with_template.png ├── abs_without_template.png ├── audiobookshelf_download_custom.png ├── authelia_custom.png ├── collage.png ├── ebook2audiobook.png ├── gluetun_summary.png ├── icon.png ├── template_collage.png ├── template_collage_rounded.png ├── vault_failed_login.gif ├── vault_failed_login.png └── vaultwarden_custom.png └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | monitor.log 2 | experimenting/ 3 | experimenting 4 | testing 5 | app/config.yaml 6 | app/__pycache__ 7 | .venv 8 | .vscode 9 | __pycache__ 10 | developing -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.11.4 2 | FROM python:${PYTHON_VERSION}-slim AS base 3 | 4 | WORKDIR /app 5 | 6 | COPY requirements.txt . 7 | 8 | LABEL org.opencontainers.image.source="https://github.com/clemcer/loggifly" 9 | 10 | RUN pip install --no-cache-dir -r requirements.txt 11 | 12 | COPY entrypoint.sh . 13 | COPY app/load_config.py . 14 | COPY app/notifier.py . 15 | COPY app/app.py . 16 | COPY app/docker_monitor.py . 17 | COPY app/line_processor.py . 18 | 19 | ENTRYPOINT ["/bin/sh", "./entrypoint.sh"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 clemcer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | Logo 6 | 7 |
8 |

LoggiFly

9 | 10 |

11 | Report Bug 12 | Request Feature 13 |

14 | 15 | 16 | 17 | **LoggiFly** - A Lightweight Tool that monitors Docker Container Logs for predefined keywords or regex patterns and sends Notifications. 18 | 19 | Get instant alerts for security breaches, system errors, or custom patterns through your favorite notification channels. 🚀 20 | 21 | 22 | **Ideal For**: 23 | - ✅ Catching security breaches (e.g., failed logins in Vaultwarden) 24 | - ✅ Debugging crashes with attached log context 25 | - ✅ Restarting containers on specific errors or stopping them completely to avoid restart loops 26 | - ✅ Monitoring custom app behaviors (e.g., when a user downloads an audiobook on your Audiobookshelf server) 27 | 28 | 29 |
30 | Failed Vaultwarden Login 31 |
32 | 33 | --- 34 | 35 | # Content 36 | 37 | - [Features](#-features) 38 | - [Screenshots](#-screenshots) 39 | - [Quick Start](#️-quick-start) 40 | - [Configuration Deep Dive](#-Configuration-Deep-Dive) 41 | - [Basic config structure](#-basic-structure) 42 | - [Settings](#%EF%B8%8F-settings) 43 | - [Notifications](#-notifications) 44 | - [Containers](#-containers) 45 | - [Global Keywords](#-global-keywords) 46 | - [Customize Notifications (Templates & Log Filtering) 47 | ](#-customize-notifications-templates--log-filtering) 48 | - [Environment Variables](#-environment-variables) 49 | - [Remote Hosts](#-remote-hosts) 50 | - [Labels](#labels) 51 | - [Remote Hosts Example](#remote-hosts-example) 52 | - [Socket Proxy](#socket-proxy) 53 | - [Docker Swarm](#docker-swarm-experimental) 54 | - [Tips](#-tips) 55 | - [Support / Buy me a coffee](#support) 56 | 57 | 58 | --- 59 | 60 | # 🚀 Features 61 | 62 | - **🔍 Plain Text, Regex & Multi-Line Log Detection**: Catch simple keywords or complex patterns in log entries that span multiple lines. 63 | - **🚨 Ntfy/Apprise Alerts**: Send notifications directly to Ntfy or via Apprise to 100+ different services (Slack, Discord, Telegram) or even to your own custom endpoint. 64 | - **🔁 Trigger Stop/Restart**: A restart/stop of the monitored container can be triggered on specific critical keywords. 65 | - **📁 Log Attachments**: Automatically include a log file to the notification for context. 66 | - **⚡ Automatic Reload on Config Change**: The program automatically reloads the `config.yaml` when it detects that the file has been changed. 67 | - **📝 Configurable Alerts**: Filter log lines for relevant information and use templates for your messages and notification titles. 68 | - **🌐 Remote Hosts**: Connect to multiple remote Docker hosts. 69 | 70 | 71 | --- 72 | # 🖼 Screenshots 73 | 74 |
75 | Audiobookshelf Download 76 |
77 | 78 |
79 | 80 | ### 🎯 Customize notifications and filter log lines for relevant information: 81 | 82 |
83 | Custom Tepmplates Collage 84 |
85 | 86 | 87 | --- 88 | 89 | >[!TIP] 90 | >For better security use a **[Docker Socket Proxy](#socket-proxy)**. 91 | You won't be able to trigger container stops/restarts with a proxy, but if you don't need that, consider taking a look at [this section](#socket-proxy) before you wrap up the Quick Start install and consider using that compose file instead of the basic one. 92 | 93 | # ⚡️ Quick start 94 | 95 | In this quickstart only the most essential settings are covered, [here](#-configuration-deep-dive) is a more detailed config walkthrough.
96 | 97 | Choose your preferred setup method - a simple docker compose with environment variables for basic use or a YAML config for advanced control. 98 | - Environment variables allow for a **simple** and **much quicker** setup 99 | - With a `config.yaml ` you can use complex **Regex patterns**, have different keywords & other settings **per container** and set keywords that trigger a **restart/stop** of the container. 100 | 101 | > [!Note] 102 | In previous versions the default location for the `config.yaml` file was `/app/config.yaml`. The old path still works (so not a breaking change) but the new official path is now `/config/config.yaml`.
103 | LoggiFly will first look in `/config/config.yaml`, and fall back to `/app/config.yaml` if it's not found.
104 | When `/config` is mounted a config template will be downloaded into that directory. 105 | 106 |
Click to expand: 🐋 Basic Setup: Docker Compose (Environment Variables) 107 |
108 | Ideal for quick setup with minimal configuration: 109 | 110 | ```yaml 111 | version: "3.8" 112 | services: 113 | loggifly: 114 | image: ghcr.io/clemcer/loggifly:latest 115 | container_name: loggifly 116 | volumes: 117 | - /var/run/docker.sock:/var/run/docker.sock:ro 118 | # This is where you would put your config.yaml file (ignore if you are only using environment variables) 119 | # - ./loggifly/config:/config 120 | environment: 121 | # Choose at least one notification service 122 | NTFY_URL: "https://ntfy.sh" 123 | NTFY_TOPIC: "your_topic" 124 | # Token or Username+Password In case you need authentication 125 | # NTFY_TOKEN: 126 | # NTFY_USERNAME: 127 | # NTFY_PASSWORD: 128 | APPRISE_URL: "discord://..." # Apprise-compatible URL 129 | 130 | CONTAINERS: "vaultwarden,audiobookshelf" # Comma-separated list 131 | GLOBAL_KEYWORDS: "error,failed login,password" # Basic keyword monitoring 132 | GLOBAL_KEYWORDS_WITH_ATTACHMENT: "critical" # Attaches a log file to the notification 133 | restart: unless-stopped 134 | 135 | ``` 136 | 137 | [Here](#-environment-variables) you can find some more environment variables that you could set. 138 | 139 | 140 |
141 | 142 | 143 |
Click to expand: 📜 Advanced Setup: YAML Configuration 144 |
145 | Recommended for granular control, regex patterns and action_keywords:
146 |
147 | 148 | **Step 1: Docker Compose** 149 | 150 | Use this [docker compose](/docker-compose.yaml) and edit this line: 151 | ```yaml 152 | volumes: 153 | - ./loggifly/config:/config # 👈 Replace left side of the mapping with your local path 154 | ``` 155 | If you want you can configure some of the settings or sensitive values like ntfy tokens or apprise URLs via [Environment Variables](#environment-variables). 156 | 157 | **Step 2: Configure Your config.yaml** 158 | 159 | If `/config` is mounted a **[template file](/config_template.yaml) will be downloaded** into that directory. You can edit the downloaded template file and rename it to `config.yaml` to use it.
160 | You can also take a look at the [Configuration-Deep-Dive](#-Configuration-Deep-Dive) for all the configuration options.
161 | 162 | Or you can just edit and copy paste the following **minimal config** into a newly created `config.yaml` file in the mounted `/config` directory: 163 | ```yaml 164 | # You have to configure at least one container. 165 | containers: 166 | container-name: # Exact container name 167 | # Configure at least one type of keywords or use global keywords 168 | keywords: 169 | - error 170 | - regex: (username|password).*incorrect # Use regex patterns when you need them 171 | # Attach a log file to the notification 172 | keywords_with_attachment: 173 | - warn 174 | # Caution advised! These keywords will trigger a restart/stop of the container 175 | # There is an action_cooldown (see config deep dive) 176 | action_keywords: 177 | - stop: traceback 178 | - restart: critical 179 | 180 | # Optional. These keywords are being monitored for all configured containers. 181 | global_keywords: 182 | keywords: 183 | - failed 184 | keywords_with_attachment: 185 | - critical 186 | 187 | notifications: 188 | # Configure either Ntfy or Apprise or both 189 | ntfy: 190 | url: http://your-ntfy-server 191 | topic: loggifly 192 | token: ntfy-token # Ntfy token in case you need authentication 193 | username: john # Ntfy Username+Password in case you need authentication 194 | password: 1234 # Ntfy Username+Password in case you need authentication 195 | apprise: 196 | url: "discord://webhook-url" # Any Apprise-compatible URL (https://github.com/caronc/apprise/wiki) 197 | ``` 198 |

199 | 200 | 201 | **When everything is configured start the container** 202 | 203 | 204 | ```bash 205 | docker compose up -d 206 | ``` 207 | 208 | --- 209 | 210 | 211 | # 🤿 Configuration Deep Dive 212 | 213 | The Quick Start only covered the essential settings, here is a more detailed walktrough of all the configuration options. 214 | 215 | 216 | ## 📁 Basic Structure 217 | 218 | The `config.yaml` file is divided into four main sections: 219 | 220 | 1. **`settings`**: Global settings like cooldowns and log levels. (_Optional since they all have default values_) 221 | 2. **`notifications`**: Ntfy (_URL, Topic, Token, Priority and Tags_), your Apprise URL and/or a custom webhook url 222 | 3. **`containers`**: Define which Containers to monitor and their specific Keywords (_plus optional settings_). 223 | 4. **`global_keywords`**: Keywords that apply to _all_ monitored Containers. 224 | 225 | 226 | > [!IMPORTANT] 227 | For the program to function you need to configure: 228 | >- **at least one container** 229 | >- **at least one notification service (Ntfy, Apprise or custom webhook)** 230 | >- **at least one keyword / regex pattern (either set globally or per container)** 231 | > 232 | > The rest is optional or has default values. 233 | 234 | [Here](/config_template.yaml) you can find a **config template** with all available configuration options and explaining comments. When `/config` is mounted in the volumes section of your docker compose this template file will automatically be downloaded.
235 | 236 | [Here](/config_example.yaml) you can find an example config with some **use cases**. 237 | 238 | 239 | ### ⚙️ Settings 240 | 241 | These are the default values for the settings: 242 | 243 |
Click to expand: Settings: 244 | 245 | ```yaml 246 | settings: 247 | log_level: INFO # DEBUG, INFO, WARNING, ERROR 248 | notification_cooldown: 5 # Seconds between alerts for same keyword (per container) 249 | notification_title: default # configure a custom template for the notification title (see section below) 250 | action_cooldown: 300 # Cooldown period (in seconds) before the next container action can be performed. Maximum is always at least 60s. 251 | attachment_lines: 20 # Number of Lines to include in log attachments 252 | multi_line_entries: True # Monitor and catch multi-line log entries instead of going line by line. 253 | reload_config: True # When the config file is changed the program reloads the config 254 | disable_start_message: False # Suppress startup notification 255 | disable_shutdown_message: False # Suppress shutdown notification 256 | disable_config_reload_message: False # Suppress config reload notification 257 | disable_container_event_message: False # Suppress notification when monitoring of containers start/stop 258 | ``` 259 |
260 | 261 | The setting `notification_title` requires a more detailed explanation:
262 | 263 |
Click to expand: notification_title: 264 |
265 | 266 | 267 | When `notification_title: default` is set LoggiFly uses its own notification titles.
268 | However, if you prefer something simpler or in another language, you can choose your own template for the notification title.
269 | This setting can also be configured per container by the way (_see [containers](#-containers) section_). 270 | 271 | These are the two keys that can be inserted into the template:
272 | `keywords`: _The keywords that were found in a log line_
273 | `container`: _The name of the container in which the keywords have been found_ 274 | 275 | 276 | 277 | Here is an example: 278 | 279 | ```yaml 280 | notification_title: "The following keywords were found in {container}: {keywords}" 281 | ``` 282 | Or keep it simple: 283 | ```yaml 284 | notification_title: {container} 285 | ``` 286 | 287 |
288 | 289 | 290 | ### 📭 Notifications 291 | 292 | You can send notifications either directly to **Ntfy** or via **Apprise** to [most other notification services](https://github.com/caronc/apprise/wiki). 293 | 294 | If you want the data to be sent to your own **custom endpoint** to integrate it into a custom workflow, you can set a custom webhook URL. LoggiFly will send all data in JSON format. 295 | 296 | You can also set all three notification options at the same time 297 | 298 | #### Ntfy: 299 | 300 |
Click to expand: Ntfy: 301 | 302 | ```yaml 303 | notifications: 304 | ntfy: 305 | url: http://your-ntfy-server # Required. The URL of your Ntfy instance 306 | topic: loggifly. # Required. the topic for Ntfy 307 | token: ntfy-token # Ntfy token in case you need authentication 308 | username: john # Ntfy Username+Password in case you need authentication 309 | password: password # Ntfy Username+Password in case you need authentication 310 | priority: 3 # Ntfy priority (1-5) 311 | tags: kite,mag # Ntfy tags/emojis 312 | ``` 313 | 314 |
315 | 316 | #### Apprise: 317 | 318 |
Click to expand: Apprise: 319 | 320 | ```yaml 321 | notifications: 322 | apprise: 323 | url: "discord://webhook-url" # Any Apprise-compatible URL (https://github.com/caronc/apprise/wiki) 324 | ``` 325 | 326 |
327 | 328 | #### Custom Webhook 329 | 330 |
Click to expand: Custom Webhook: 331 | 332 | ```yaml 333 | notifications: 334 | webhook: 335 | url: https://custom.endpoint.com/post 336 | # add headers if needed 337 | headers: 338 | Authorization: "Bearer token" 339 | X-Custom-Header": "Test123" 340 | ``` 341 | 342 | If a **webhook** is configured LoggiFly will post a JSON to the URL with the following data: 343 | ```yaml 344 | { 345 | "container": "...", 346 | "keywords": [...], 347 | "title": "...", 348 | "message": "...", 349 | "host": "..." # None unless multiple hosts are monitored 350 | } 351 | 352 | ``` 353 | 354 |
355 | 356 | ### 🐳 Containers 357 | 358 | Here you can define containers and assign keywords, regex patterns, and optional settings to each one.
359 | The container names must match the exact container names you would get with `docker ps`.
360 | 361 |
Click to expand: Container Config: 362 | 363 | This is how you configure **keywords, regex patterns and action_keywords**. `action_keywords` trigger a start/stop of the monitored container: 364 | 365 | ```yaml 366 | containers: 367 | container1: 368 | keywords: 369 | - keyword1 370 | - regex: regex-patern1 # this is how to set regex patterns 371 | keywords_with_attachment: # attach a logfile to the notification 372 | - keyword2 373 | - regex: regex-pattern2 374 | hide_pattern_in_title: true # Exclude the regex pattern from the notification title for a cleaner look. Useful when using very long regex patterns. 375 | - keyword3 376 | action_keywords: # trigger a restart/stop of the container. can not be set globally 377 | - restart: keyword4 378 | - stop: 379 | regex: regex-pattern3 # this is how to set regex patterns for action_keywords 380 | 381 | ``` 382 | 383 |
384 | 385 | Some of the **settings** from the `settings` section can also be set per container: 386 | 387 | 388 | ```yaml 389 | containers: 390 | container2: 391 | ntfy_tags: closed_lock_with_key 392 | ntfy_priority: 5 393 | ntfy_topic: container3 394 | attachment_lines: 50 395 | notification_title: '{keywords} found in {container}' 396 | notification_cooldown: 2 397 | action_cooldown: 60 398 | 399 | keywords: 400 | - keyword1 401 | - regex: regex-pattern1 402 | 403 | ``` 404 | 405 |
406 | 407 | If `global_keywords` are configured and you don't need additional keywords for a container you can **leave it blank**: 408 | 409 | ```yaml 410 | containers: 411 | container3: 412 | container4: 413 | ``` 414 | 415 |
416 | 417 | 418 | ### 🌍 Global Keywords 419 | 420 | When `global_keywords` are configured all containers are monitored for these keywords: 421 | 422 |
Click to expand: Global Keywords: 423 | 424 | ```yaml 425 | global_keywords: 426 | keywords: 427 | - error 428 | keywords_with_attachment: # attach a logfile 429 | - regex: (critical|error) 430 | ``` 431 |
432 | 433 |
434 | 435 | 436 | ## 📝 Customize Notifications (Templates & Log Filtering) 437 | 438 | 439 | For users who want more control over the appearance of their notifications, there is an option to configure templates and filter log entries to display only the relevant parts.
440 | Here are some [examples](#-customize-notifications-and-filter-log-lines-for-relevant-information).
441 | Filtering is most straightforward with logs in JSON Format, but plain text logs can also be parsed by using named groups in the regex pattern.
442 | 443 | > [!Note] 444 | > If you want to modify the notification title take a look at the setting `notification_title` in the [settings section](#%EF%B8%8F-settings). 445 | 446 | 447 |
Click to expand: Filter Logs and set custom template: 448 | 449 |
450 | 451 | 452 | #### Template for JSON Logs: 453 | 454 | 455 | `json_template` only works if the Logs are in JSON Format. Authelia is one such example.
456 | You can only use the placeholder variables that exist as keys in the JSON from the log line you want to catch.
457 | 458 | Here is an example where you want to catch this very long log entry from Authelia: 459 | 460 | ``` 461 | {"level":"error","method":"POST","msg":"Unsuccessful 1FA authentication attempt by user 'example_user' and they are banned until 12:23:00PM on May 1 2025 (+02:00)","path":"/api/firstfactor","remote_ip":"192.168.178.191","stack":[{"File":"github.com/authelia/authelia/v4/internal/handlers/response.go","Line":274,"Name":"doMarkAuthenticationAttemptWithRequest"},{"File":"github.com/authelia/authelia/v4/internal/handlers/response.go","Line":258,"Name":"doMarkAuthenticationAttempt"},{"File":"github.com/authelia/authelia/v4/internal/handlers/handler_firstfactor_password.go","Line":51,"Name":"handlerMain.FirstFactorPasswordPOST.func14"},{"File":"github.com/authelia/authelia/v4/internal/middlewares/bridge.go","Line":66,"Name":"handlerMain.(*BridgeBuilder).Build.func7.1"},{"File":"github.com/authelia/authelia/v4/internal/middlewares/headers.go","Line":65,"Name":"SecurityHeadersCSPNone.func1"},{"File":"github.com/authelia/authelia/v4/internal/middlewares/headers.go","Line":105,"Name":"SecurityHeadersNoStore.func1"},{"File":"github.com/authelia/authelia/v4/internal/middlewares/headers.go","Line":30,"Name":"SecurityHeadersBase.func1"},{"File":"github.com/fasthttp/router@v1.5.4/router.go","Line":441,"Name":"(*Router).Handler"},{"File":"github.com/authelia/authelia/v4/internal/middlewares/log_request.go","Line":14,"Name":"handlerMain.LogRequest.func31"},{"File":"github.com/authelia/authelia/v4/internal/middlewares/errors.go","Line":38,"Name":"RecoverPanic.func1"},{"File":"github.com/valyala/fasthttp@v1.59.0/server.go","Line":2380,"Name":"(*Server).serveConn"},{"File":"github.com/valyala/fasthttp@v1.59.0/workerpool.go","Line":225,"Name":"(*workerPool).workerFunc"},{"File":"github.com/valyala/fasthttp@v1.59.0/workerpool.go","Line":197,"Name":"(*workerPool).getCh.func1"},{"File":"runtime/asm_amd64.s","Line":1700,"Name":"goexit"}],"time":"2025-05-01T14:19:29+02:00"} 462 | ``` 463 | 464 | In the config.yaml you can set a `json_template` for both plain text keywords and regex patterns. In the template I inserted three keys from the JSON Log Entry: 465 | ```yaml 466 | containers: 467 | authelia: 468 | keywords: 469 | - keyword: Unsuccessful 1FA authentication 470 | json_template: '🚨 Failed Login Attempt:\n{msg}\n🔎 IP: {remote_ip}\n🕐{time}' 471 | - regex: Unsuccessful.*authentication 472 | json_template: '🚨 Failed Login Attempt:\n{msg}\n🔎 IP: {remote_ip}\n🕐{time}' 473 | ``` 474 |
475 | 476 | #### Template using named capturing groups in Regex Pattern: 477 | 478 | To filter non JSON Log Lines for certain parts you have to use a regex pattern with **named capturing groups**.
479 | Lets take `(?P...)` as an example. 480 | `P` assigns the name `group_name` to the group. 481 | The part inside the parentheses `(...)` is the pattern to match.
482 | Then you can insert the `{group_name}` into your custom message `template`. 483 |
484 | 485 | Example Log Line from audiobookshelf: 486 | 487 | ``` 488 | [2025-05-03 10:16:53.154] INFO: [SocketAuthority] Socket VKrcSNa--FjwAqmSAAAU disconnected from client "example user" after 11696ms (Reason: transport close) 489 | ``` 490 | 491 | Regex pattern & Template: 492 | 493 | ```yaml 494 | containers: 495 | audiobookshelf: 496 | keywords: 497 | - regex: '(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}).*Socket.*disconnected from client "(?P[A-Za-z\s]+)"' 498 | template: '\n🔎 The user {user} was seen!\n🕐 {timestamp}' 499 | hide_pattern_in_title: true # Exclude the regex pattern from the notification title for a cleaner look 500 | 501 | ``` 502 | 503 | **Result:** 504 | 505 | - with `template` and `hide_pattern_in_title`: 506 | 507 |
508 | 509 |
510 | 511 | - without for comparison: 512 | 513 |
514 | 515 |
516 | 517 | 518 |
519 | 520 | ### Add original Log Entry to template: 521 | 522 | WIth both `json_template` and `template` you can add the key `original_log_line` to your template to add the full log entry to your notification message. 523 | 524 |
525 |
526 | 527 | 528 | ## 🍀 Environment Variables 529 | 530 | Except for `action_keywords`, container specific settings/keywords and regex patterns you can configure most settings via **Docker environment variables**. 531 | 532 |
Click to expand: Environment Variables
533 | 534 | 535 | | Variables | Description | Default | 536 | |-----------------------------------|----------------------------------------------------------|----------| 537 | | `NTFY_URL` | URL of your Ntfy server instance | _N/A_ | 538 | | `NTFY_TOKEN` | Authentication token for Ntfy in case you need authentication. | _N/A_ | 539 | | `NTFY_USERNAME` | Ntfy Username to use with the password in case you need authentication. | _N/A_ | 540 | | `NTFY_PASSWORD` | Ntfy password to use with the username in case you need authentication. | _N/A_ | 541 | | `NTFY_TOPIC` | Notification topic for Ntfy. | _N/A_ | 542 | | `NTFY_TAGS` | [Tags/Emojis](https://docs.ntfy.sh/emojis/) for ntfy notifications. | kite,mag | 543 | | `NTFY_PRIORITY` | Notification [priority](https://docs.ntfy.sh/publish/?h=priori#message-priority) for ntfy messages. | 3 / default | 544 | | `APPRISE_URL` | Any [Apprise-compatible URL](https://github.com/caronc/apprise/wiki) | _N/A_ | 545 | | `CONTAINERS` | A comma separated list of containers. These are added to the containers from the config.yaml (if you are using one).| _N/A_ | 546 | | `SWARM_SERVICES` | A comma separated list of docker swarm services to monitor. | _N/A_ | 547 | | `LOGGIFLY_MODE` | Set this variable to `swarm` when wanting to use LoggiFly in swarm mode | _N/A_ | 548 | | `GLOBAL_KEYWORDS` | Keywords that will be monitored for all containers. Overrides `global_keywords.keywords` from the config.yaml.| _N/A_ | 549 | | `GLOBAL_KEYWORDS_WITH_ATTACHMENT`| Notifications triggered by these global keywords have a logfile attached. Overrides `global_keywords.keywords_with_attachment` from the config.yaml.| _N/A_ | 550 | | `NOTIFICATION_COOLDOWN` | Cooldown period (in seconds) per container per keyword before a new message can be sent | 5 | 551 | | `ACTION_COOLDOWN` | Cooldown period (in seconds) before the next container action can be performed. Always at least 60s. (`action_keywords` are only configurable in YAML) | 300 | 552 | | `LOG_LEVEL` | Log Level for LoggiFly container logs. | INFO | 553 | | `MULTI_LINE_ENTRIES` | When enabled the program tries to catch log entries that span multiple lines.
If you encounter bugs or you simply don't need it you can disable it.| True | 554 | | `ATTACHMENT_LINES` | Define the number of Log Lines in the attachment file | 20 | 555 | | `RELOAD_CONFIG` | When the config file is changed the program reloads the config | True | 556 | | `DISBLE_START_MESSAGE` | Disable startup message. | False | 557 | | `DISBLE_SHUTDOWN_MESSAGE` | Disable shutdown message. | False | 558 | | `DISABLE_CONFIG_RELOAD_MESSAGE` | Disable message when the config file is reloaded.| False | 559 | | `DISABLE_CONTAINER_EVENT_MESSAGE` | Disable message when the monitoring of a container stops or starts.| False | 560 | 561 |
562 | 563 | --- 564 | 565 | # 📡 Remote Hosts 566 | 567 | LoggiFly supports connecting to **multiple remote hosts**.
568 | Remote hosts can be configured by providing a **comma-separated list of addresses** in the `DOCKER_HOST` environment variable.
569 | To use **TLS** you have to mount `/certs` in the volumes section of your docker compose.
570 | LoggiFly expects the TLS certificates to be in `/certs/{ca,cert,key}.pem` or in case of multiple hosts `/certs/{host}/{ca,cert,key}.pem` with `{host}` being either the IP or FQDN.
571 | You can also combine remote hosts with a mounted docker socket.
572 | 573 | >[!NOTE] 574 | When the connection to a docker host is lost, LoggiFly will try to reconnect every 60s 575 | 576 | ## Labels 577 | When multiple hosts are set LoggiFly will use **labels** to differentiate between them both in notifications and in logging.
578 | You can set a **label** by appending it to the address with `"|"` ([_see example_](#remote-hosts-example)).
579 | When no label is set LoggiFly will use the **hostname** retrieved via the docker daemon. If that fails, usually because `INFO=1` has to be set when using a proxy, the labels will just be `Host-{Nr}`.
580 | 581 | If you want to set a label to the mounted docker socket you can do so by adding `unix:///var/run/docker.sock|label` in the `DOCKER_HOST` environment variable (_the socket still has to be mounted_) or just set the address of a [socket proxy](#socket-proxy) with a label. 582 | 583 | ## Remote Hosts Example 584 | 585 | In this example, LoggiFly monitors container logs from the **local host** via a mounted Docker socket, as well as from **two remote Docker hosts** configured with TLS. One of the remote hosts is referred to as ‘foobar’. The local host and the second remote host have no custom label and are identified by their respective hostnames. 586 | 587 |
Click to expand: Remote Hosts: Docker Compose 588 | 589 | ```yaml 590 | version: "3.8" 591 | services: 592 | loggifly: 593 | image: ghcr.io/clemcer/loggifly:latest 594 | container_name: loggifly 595 | volumes: 596 | - /var/run/docker.sock:/var/run/docker.sock:ro 597 | - ./loggifly/config:/config # Place your config.yaml here if you are using one 598 | - ./certs:/certs 599 | # Assuming the Docker hosts use TLS, the folder structure for the certificates should be like this: 600 | # /certs/ 601 | # ├── 192.168.178.80/ 602 | # │ ├── ca.pem 603 | # │ ├── cert.pem 604 | # │ └── key.pem 605 | # └── 192.168.178.81/ 606 | # ├── ca.pem 607 | # ├── cert.pem 608 | # └── key.pem 609 | environment: 610 | TZ: Europe/Berlin 611 | DOCKER_HOST: tcp://192.168.178.80:2376,tcp://192.168.178.81:2376|foobar 612 | restart: unless-stopped 613 | ``` 614 |
615 | 616 | ## Socket Proxy 617 | 618 | You can also connect via a **Docker Socket Proxy**.
619 | A Socket Proxy adds a security layer by **controlling access to the Docker daemon**, essentially letting LoggiFly only read certain info like container logs without giving it full control over the docker socket.
620 | With the linuxserver image I have had some connection and timeout problems so the recommended proxy is **[Tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)**.
621 | When using the Tecnativa Proxy the log stream connection drops every ~10 minutes for whatever reason, LoggiFly simply resets the connection.
622 | 623 | Here is a sample **docker compose** file: 624 | 625 |
Click to expand: Socket Proxy: Docker Compose 626 | 627 | ```yaml 628 | version: "3.8" 629 | services: 630 | loggifly: 631 | image: ghcr.io/clemcer/loggifly:latest 632 | container_name: loggifly 633 | volumes: 634 | - ./loggifly/config:/config # Place your config.yaml here if you are using one 635 | environment: 636 | TZ: Europe/Berlin 637 | DOCKER_HOST: tcp://socket-proxy:2375 638 | depends_on: 639 | - socket-proxy 640 | restart: unless-stopped 641 | 642 | socket-proxy: 643 | image: tecnativa/docker-socket-proxy 644 | container_name: docker-socket-proxy 645 | environment: 646 | - CONTAINERS=1 647 | - POST=0 648 | volumes: 649 | - /var/run/docker.sock:/var/run/docker.sock:ro 650 | restart: unless-stopped 651 | 652 | ``` 653 |
654 | 655 | >[!Note] 656 | `action_keywords` don't work when using a socket proxy. 657 |
658 | 659 | 660 | # Docker Swarm (_Experimental_) 661 | 662 | > [!Important] 663 | Docker Swarm Support is still experimental because I have little to no experience with it and can not say for certain whether it works flawlessly. 664 | If you notice any bugs or have suggestions let me know. 665 | 666 | To use LoggiFly in swarm mode you have to set the environment variable `LOGGIFLY_MODE` to `swarm`.
667 | 668 | The `config.yaml` is passed to each worker via [Docker Configs](https://docs.docker.com/reference/cli/docker/config/) (_see example_).
669 | 670 | The configuration pretty much stays the same except that you set `swarm_services` instead of `containers` or use the `SWARM_SERVICES` environment variable.
671 | 672 | If normal `containers` are set instead of or additionally to `swarm_services` LoggiFly will also look for these containers on every node. 673 | 674 | **Docker Compose** 675 | 676 |
Click to expand: Docker Compose 677 | 678 | ```yaml 679 | version: "3.8" 680 | 681 | services: 682 | loggifly: 683 | image: ghcr.io/clemcer/loggifly:latest 684 | deploy: 685 | mode: global # runs on every node 686 | restart_policy: 687 | condition: any 688 | delay: 5s 689 | max_attempts: 5 690 | volumes: 691 | - /var/run/docker.sock:/var/run/docker.sock:ro 692 | environment: 693 | TZ: Europe/Berlin 694 | LOGGIFLY_MODE: swarm 695 | # Uncomment the next three variables if you want to only use environment variables instead of a config.yaml 696 | # SWARM_SERVICES: nginx,redis 697 | # GLOBAL_KEYWORDS: keyword1,keyword2 698 | # GLOBAL_KEYWORDS_WITH_ATTACHMENT: keyword3 699 | # For more environment variables see the environment variables section in the README 700 | # Comment out the rest of this file if you are only using environment variables 701 | configs: 702 | - source: loggifly-config 703 | target: /config/config.yaml 704 | 705 | configs: 706 | loggifly-config: 707 | file: ./loggifly/config.yaml # SET YOU THE PATH TO YOUR CONFIG.YAML HERE 708 | 709 | ``` 710 |
711 | 712 | **Config.yaml** 713 | 714 |
Click to expand: config.yaml 715 | 716 | In the `config.yaml` you can set services that should be monitored just like you would do with containers. 717 | 718 | ```yaml 719 | swarm_services: 720 | nginx: 721 | keywords: 722 | - error 723 | redis: 724 | keywords_with_attachment: 725 | - fatal 726 | ``` 727 | 728 | If both nginx and redis are part of the same compose stack named `my_service` you can configure that service name to monitor both: 729 | ```yaml 730 | swarm_services: 731 | my_service: # includes my_service_nginx and my_service_redis 732 | keywords: 733 | - error 734 | keywords_with_attachment: 735 | - fatal 736 | ``` 737 | 738 | For all available configuration options, refer to the [Containers section](#-containers) of the configuration walkthrough — the `swarm_services` configuration is identical to that of `containers`. 739 | 740 |
741 | 742 | 743 | 744 | # 💡 Tips 745 | 746 | 1. Ensure containers names **exactly match** your Docker **container names**. 747 | - Find out your containers names: ```docker ps --format "{{.Names}}" ``` 748 | - 💡 Pro Tip: Define the `container_name:` in your compose files. 749 | 2. **`action_keywords`** can not be set via environment variables, they can only be set per container in the `config.yaml`. The `action_cooldown` is always at least 60s long and defaults to 300s 750 | 3. **Regex Patterns**: 751 | - Validate patterns at [regex101.com](https://regex101.com) before adding them to your config. 752 | - use `hide_pattern_in_title: true` when using very long regex patterns to have a cleaner notification title _(or hide found keywords from the title altogether with your own custom `notification_title` ([see settings](#%EF%B8%8F-settings))_ 753 | 5. **Troubleshooting Multi-Line Log Entries**. If LoggiFly only catches single lines from log entries that span over multiple lines: 754 | - Wait for Patterns: LoggiFly needs to process a few lines in order to detect the pattern the log entries start with (e.g. timestamps/log level) 755 | - Unrecognized Patterns: If issues persist, open an issue and share the affected log samples 756 | 757 | --- 758 | 759 | 760 | # Support 761 | 762 | If you find LoggiFly useful, drop a ⭐️ on the repo 763 | 764 |

765 | Buy Me A Coffee 766 |

767 | 768 | 769 | # Star History 770 | 771 | [![Star History Chart](https://api.star-history.com/svg?repos=clemcer/loggifly&type=Date)](https://www.star-history.com/#clemcer/loggifly&Date) 772 | 773 | ## License 774 | [MIT](https://github.com/clemcer/LoggiFly/blob/main/LICENSE) 775 | -------------------------------------------------------------------------------- /app/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import signal 5 | import threading 6 | import logging 7 | import traceback 8 | import docker 9 | from threading import Timer 10 | from docker.tls import TLSConfig 11 | from urllib.parse import urlparse 12 | from pydantic import ValidationError 13 | from typing import Any 14 | from watchdog.observers import Observer 15 | from watchdog.events import FileSystemEventHandler 16 | 17 | from load_config import load_config, format_pydantic_error 18 | from docker_monitor import DockerLogMonitor 19 | from notifier import send_notification 20 | 21 | 22 | logging.basicConfig( 23 | level="INFO", 24 | format="%(asctime)s - %(levelname)s - %(message)s", 25 | handlers=[ 26 | logging.StreamHandler() 27 | ] 28 | ) 29 | logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) 30 | logging.getLogger("docker").setLevel(logging.INFO) 31 | logging.getLogger("watchdog").setLevel(logging.WARNING) 32 | 33 | 34 | def create_handle_signal(monitor_instances, config, config_observer): 35 | global_shutdown_event = threading.Event() 36 | 37 | def handle_signal(signum, frame): 38 | if not config.settings.disable_shutdown_message: 39 | send_notification(config, "LoggiFly", "LoggiFly", "Shutting down") 40 | if config_observer is not None: 41 | config_observer.stop() 42 | config_observer.join() 43 | threads = [] 44 | for monitor in monitor_instances: 45 | monitor.shutdown_event.set() 46 | thread = threading.Thread(target=monitor.cleanup) 47 | threads.append(thread) 48 | thread.start() 49 | for thread in threads: 50 | thread.join(timeout=2) 51 | global_shutdown_event.set() 52 | 53 | return handle_signal, global_shutdown_event 54 | 55 | 56 | class ConfigHandler(FileSystemEventHandler): 57 | """ 58 | When a config.yaml change is detected, the reload_config method is called on each host's DockerLogMonitor instance. 59 | This method then updates all LogProcessor instances (from line_processor.py) by calling their load_config_variables function. 60 | This ensures that any new keywords, settings, or other configuration changes are correctly applied especiaööy in the code that handles keyword searching in line_processor.py. 61 | """ 62 | def __init__(self, monitor_instances, config): 63 | self.monitor_instances = monitor_instances 64 | self.last_config_reload_time = 0 65 | self.config = config 66 | self.reload_timer = None 67 | self.debounce_seconds = 2 68 | 69 | def on_modified(self, event): 70 | if self.config.settings.reload_config and not event.is_directory and event.src_path.endswith('config.yaml'): 71 | if self.reload_timer: 72 | self.reload_timer.cancel() 73 | self.reload_timer = Timer(self.debounce_seconds, self._trigger_reload) 74 | self.reload_timer.start() 75 | 76 | def _trigger_reload(self): 77 | logging.info("Config change detected, reloading config...") 78 | try: 79 | self.config, _ = load_config() 80 | except ValidationError as e: 81 | logging.critical(f"Error reloading config (using old config): {format_pydantic_error(e)}") 82 | return 83 | for monitor in self.monitor_instances: 84 | monitor.reload_config(self.config) 85 | if not self.config.settings.reload_config: 86 | self.observer.stop() 87 | self.logger.info("Config watcher stopped because reload_config is set to False.") 88 | 89 | 90 | def start_config_watcher(monitor_instances, config, path): 91 | observer = Observer() 92 | observer.schedule(ConfigHandler(monitor_instances, config), path=path, recursive=False) 93 | observer.start() 94 | return observer 95 | 96 | 97 | def check_monitor_status(docker_hosts, global_shutdown_event): 98 | """ 99 | Every 60s this function checks whether the docker hosts are still monitored and tries to reconnect if the connection is lost. 100 | """ 101 | def check_and_reconnect(): 102 | while True: 103 | time.sleep(60) 104 | for host, values in docker_hosts.items(): 105 | monitor = values["monitor"] 106 | if monitor.shutdown_event.is_set(): 107 | while monitor.cleanup_event.is_set(): 108 | time.sleep(1) 109 | if global_shutdown_event.is_set(): 110 | return 111 | tls_config, label = values["tls_config"], values["label"] 112 | new_client = None 113 | try: 114 | new_client = docker.DockerClient(base_url=host, tls=tls_config) 115 | except docker.errors.DockerException as e: 116 | logging.warning(f"Could not reconnect to {host} ({label}): {e}") 117 | except Exception as e: 118 | logging.warning(f"Could not reconnect to {host} ({label}). Unexpected error creating Docker client: {e}") 119 | if new_client: 120 | logging.info(f"Successfully reconnected to {host} ({label})") 121 | monitor.shutdown_event.clear() 122 | monitor.start(new_client) 123 | monitor.reload_config(None) 124 | 125 | thread = threading.Thread(target=check_and_reconnect, daemon=True) 126 | thread.start() 127 | return thread 128 | 129 | 130 | def create_docker_clients() -> dict[str, dict[str, Any]]: # {host: {client: DockerClient, tls_config: TLSConfig, label: str}} 131 | """ 132 | This function creates Docker clients for all hosts specified in the DOCKER_HOST environment variables + the mounted docker socket. 133 | TLS certificates are searched in '/certs/{ca,cert,key}'.pem 134 | or '/certs/{host}/{ca,cert,key}.pem' (to use in case of multiple hosts) with {host} being the IP or FQDN of the host. 135 | """ 136 | def get_tls_config(hostname): 137 | cert_locations = [ 138 | (os.path.join("/certs", hostname)), 139 | (os.path.join("/certs")) 140 | ] 141 | 142 | for cert_dir in cert_locations: 143 | logging.debug(f"Checking TLS certs for {hostname} in {cert_dir}") 144 | ca = os.path.join(cert_dir, "ca.pem") 145 | cert = os.path.join(cert_dir, "cert.pem") 146 | key = os.path.join(cert_dir, "key.pem") 147 | 148 | if all(os.path.exists(f) for f in [ca, cert, key]): 149 | logging.debug(f"Found TLS certs for {hostname} in {cert_dir}") 150 | return TLSConfig(client_cert=(cert, key), ca_cert=ca, verify=True) 151 | return None 152 | 153 | docker_host = os.environ.get("DOCKER_HOST", "") 154 | logging.debug(f"Environment variable DOCKER_HOST: {os.environ.get('DOCKER_HOST', ' - Not configured - ')}") 155 | tmp_hosts = [h.strip() for h in docker_host.split(",") if h.strip()] 156 | hosts = [] 157 | for host in tmp_hosts: 158 | label = None 159 | if "|" in host: 160 | host, label = host.split("|", 1) 161 | hosts.append((host, label.strip()) if label else (host.strip(), None)) 162 | 163 | if os.path.exists("/var/run/docker.sock"): 164 | logging.debug(f"Path to docker socket exists: True") 165 | if not any(h[0] == "unix:///var/run/docker.sock" for h in hosts): 166 | hosts.append(("unix:///var/run/docker.sock", None)) 167 | 168 | logging.debug(f"Configured docker hosts to connect to: {[host for (host, _) in hosts]}") 169 | 170 | if len(hosts) == 0: 171 | logging.critical("No docker hosts configured. Please set the DOCKER_HOST environment variable or mount your docker socket.") 172 | 173 | 174 | docker_hosts = {} 175 | for host, label in hosts: 176 | logging.info(f"Trying to connect to docker client on host: {host}") 177 | parsed = urlparse(host) 178 | tls_config = None 179 | if parsed.scheme == "unix": 180 | pass 181 | elif parsed.scheme == "tcp": 182 | hostname = parsed.hostname 183 | tls_config = get_tls_config(hostname) 184 | try: 185 | client = docker.DockerClient(base_url=host, tls=tls_config, timeout=10) 186 | docker_hosts[host] = {"client": client, "tls_config": tls_config, "label": label} 187 | except docker.errors.DockerException as e: 188 | logging.error(f"Error creating Docker client for {host}: {e}") 189 | logging.debug(f"Traceback: {traceback.format_exc()}") 190 | continue 191 | except Exception as e: 192 | logging.error(f"Unexpected error creating Docker client for {host}: {e}") 193 | logging.debug(f"Traceback: {traceback.format_exc()}") 194 | continue 195 | 196 | if len(docker_hosts) == 0: 197 | logging.critical("Could not connect to any docker hosts. Please check your DOCKER_HOST environment variable or mounted docker socket.") 198 | logging.info("Waiting 10s to prevent restart loop...") 199 | time.sleep(10) 200 | sys.exit(1) 201 | logging.info(f"Connections to Docker-Clients established for {', '.join([host for host in docker_hosts.keys()])}" 202 | if len(docker_hosts.keys()) > 1 else "Connected to Docker Client") 203 | return docker_hosts 204 | 205 | 206 | def start_loggifly(): 207 | try: 208 | config, path = load_config() 209 | except ValidationError as e: 210 | logging.critical(f"Error loading Config: {format_pydantic_error(e)}") 211 | logging.info("Waiting 15s to prevent restart loop...") 212 | time.sleep(15) 213 | sys.exit(1) 214 | 215 | logging.getLogger().setLevel(getattr(logging, config.settings.log_level.upper(), logging.INFO)) 216 | logging.info(f"Log-Level set to {config.settings.log_level}") 217 | 218 | docker_hosts = create_docker_clients() 219 | hostname = "" 220 | for number, (host, values) in enumerate(docker_hosts.items(), start=1): 221 | client, label = values["client"], values["label"] 222 | if len(docker_hosts.keys()) > 1: 223 | try: 224 | hostname = label if label else client.info()["Name"] 225 | except Exception as e: 226 | hostname = f"Host-{number}" 227 | logging.warning(f"Could not get hostname for {host}. LoggiFly will call this host '{hostname}' in notifications and in logging to differentiate it from the other hosts." 228 | f"\nThis error might have been raised because you are using a Socket Proxy without the environment variable 'INFO=1'" 229 | f"\nYou can also set a label for the docker host in the DOCKER_HOST environment variable like this: 'tcp://host:2375|label' to use instead of the hostname." 230 | f"\nError details: {e}") 231 | 232 | logging.info(f"Starting monitoring for {host} {'(' + hostname + ')' if hostname else ''}") 233 | monitor = DockerLogMonitor(config, hostname, host) 234 | monitor.start(client) 235 | docker_hosts[host]["monitor"] = monitor 236 | 237 | monitor_instances = [docker_hosts[host]["monitor"] for host in docker_hosts.keys()] 238 | # Start config observer to catch config.yaml changes 239 | if config.settings.reload_config and isinstance(path, str) and os.path.exists(path): 240 | config_observer = start_config_watcher(monitor_instances, config, path) 241 | else: 242 | logging.debug("Config watcher was not started because reload_config is set to False or because no valid config path exist.") 243 | config_observer = None 244 | 245 | handle_signal, global_shutdown_event = create_handle_signal(monitor_instances, config, config_observer) 246 | signal.signal(signal.SIGTERM, handle_signal) 247 | signal.signal(signal.SIGINT, handle_signal) 248 | 249 | # Start the thread that checks whether the docker hosts are still monitored and tries to reconnect if the connection is lost. 250 | check_monitor_status(docker_hosts, global_shutdown_event) 251 | return global_shutdown_event 252 | 253 | 254 | if __name__ == "__main__": 255 | global_shutdown_event = start_loggifly() 256 | global_shutdown_event.wait() 257 | 258 | -------------------------------------------------------------------------------- /app/docker_monitor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import socket 4 | import traceback 5 | import time 6 | import os 7 | import random 8 | import requests 9 | import docker 10 | from datetime import datetime 11 | from notifier import send_notification 12 | from line_processor import LogProcessor 13 | 14 | class DockerLogMonitor: 15 | """ 16 | In this class a thread is started for every container that is running and set in the config 17 | One thread is started to monitor docker events to watch for container starts/stops to start or stop the monitoring of containers. 18 | There are a few collections that are referenced between functions I want to document here: 19 | 20 | - self.line_processor_instances: Dict keyed by container.name, each containing a dict with {'processor' processor, 'container_stop_event': container_stop_event} 21 | - processor is an instance of the LogProcessor class (line_processor.py) and is gets fed the log lines from the container one by one to search for keywords among other things. 22 | It is stored so that the reload_config_variables function can be called to update keywords, settings, etc when the config.yaml changes. 23 | When a container is stopped and started again the old processor instance is re-used. 24 | - container_stop_event is a threading event used to stop the threads that are running to monitor one container (log_monitor (with log stream and flush thread) 25 | 26 | - self.stream_connections: Dict of log stream connections keyed by container.name (effectively releasing the blocking log stream allowing the threads to stop) 27 | 28 | - self.monitored_containers: Dict of Docker Container objects that are currently being monitored keyed by container.id 29 | 30 | - self.selected_containers: List of container names that are set in the config 31 | 32 | - self.threads: List of threads that are started to monitor container logs and docker events 33 | 34 | """ 35 | def __init__(self, config, hostname, host): 36 | self.hostname = hostname # empty string if only one client is being monitored, otherwise the hostname of the client do differentiate between the hosts 37 | self.host = host 38 | self.config = config 39 | self.swarm_mode = os.getenv("LOGGIFLY_MODE").strip().lower() == "swarm" if os.getenv("LOGGIFLY_MODE") else False 40 | 41 | self.selected_containers = [c for c in self.config.containers] if self.config.containers else [] 42 | self.selected_swarm_services = [s for s in self.config.swarm_services] if config.swarm_services else [] 43 | self.shutdown_event = threading.Event() 44 | self.cleanup_event = threading.Event() 45 | self.threads = [] 46 | self.threads_lock = threading.Lock() 47 | self.line_processor_instances = {} 48 | self.processors_lock = threading.Lock() 49 | 50 | self.stream_connections = {} 51 | self.stream_connections_lock = threading.Lock() 52 | self.monitored_containers = {} 53 | 54 | def init_logging(self): 55 | """The hostname is added to logs when there are multiple hosts or when using docker swarm to differentiate between the hosts/nodes""" 56 | self.logger = logging.getLogger(f"Monitor-{self.hostname}") 57 | self.logger.handlers.clear() 58 | handler = logging.StreamHandler() 59 | formatter = (logging.Formatter(f'%(asctime)s - %(levelname)s - [Host: {self.hostname}] - %(message)s') 60 | if self.hostname else logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) 61 | handler.setFormatter(formatter) 62 | self.logger.addHandler(handler) 63 | self.logger.setLevel(getattr(logging, self.config.settings.log_level.upper(), logging.INFO)) 64 | self.logger.propagate = False 65 | 66 | def _add_thread(self, thread): 67 | with self.threads_lock: 68 | self.threads.append(thread) 69 | 70 | def _add_processor_instance(self, processor, container_stop_event, container_name): 71 | with self.processors_lock: 72 | if container_name not in self.line_processor_instances: 73 | self.line_processor_instances[container_name] = {"processor": processor, "container_stop_event": container_stop_event} 74 | return True 75 | else: 76 | return False 77 | 78 | def _add_stream_connection(self, container_name, connection): 79 | with self.stream_connections_lock: 80 | self.stream_connections[container_name] = connection 81 | 82 | def _close_stream_connection(self, container_name): 83 | stream = self.stream_connections.get(container_name) 84 | container_stop_event = self.line_processor_instances[container_name]["container_stop_event"] 85 | container_stop_event.set() 86 | if stream: 87 | self.logger.info(f"Closing Log Stream connection for {container_name}") 88 | try: 89 | stream.close() 90 | self.stream_connections.pop(container_name) 91 | except Exception as e: 92 | self.logger.warning(f"Error trying do close log stream for {container_name}: {e}") 93 | else: 94 | self.logger.debug(f"Could not find log stream connection for container {container_name}") 95 | 96 | 97 | def _check_if_swarm_to_monitor(self, container): 98 | labels = container.labels 99 | service_name = labels.get("com.docker.swarm.service.name", "") 100 | if service_name: 101 | for configured in self.selected_swarm_services: 102 | if service_name.startswith(configured): 103 | return configured 104 | return None 105 | 106 | # This function is called from outside this class to start the monitoring 107 | def start(self, client): 108 | self.client = client 109 | if self.swarm_mode: 110 | # Find out if manager or worker and set hostname to differentiate between the instances 111 | try: 112 | swarm_info = client.info().get("Swarm") 113 | node_id = swarm_info.get("NodeID") 114 | except Exception as e: 115 | self.logger.error(f"Could not get info via docker client. Needed to get info about swarm role (manager/worker)") 116 | node_id = None 117 | if node_id: 118 | try: 119 | node = client.nodes.get(node_id) 120 | manager = True if node.attrs["Spec"]["Role"] == "manager" else False 121 | except Exception as e: 122 | manager = False 123 | try: 124 | self.hostname = ("manager" if manager else "Worker") + "@" + self.client.info()["Name"] 125 | except Exception as e: 126 | self.hostname = ("manager" if manager else "worker") + "@" + socket.gethostname() 127 | self.init_logging() 128 | if self.swarm_mode: 129 | self.logger.info(f"Running in swarm mode.") 130 | 131 | for container in self.client.containers.list(): 132 | if self.swarm_mode: 133 | # if the container belongs to a swarm service that is set in the config the service name has to be saved for later use 134 | swarm_service_name = self._check_if_swarm_to_monitor(container) 135 | if swarm_service_name: 136 | self.logger.debug(f"Trying to monitor container of swarm service: {swarm_service_name}") 137 | self._monitor_container(container, swarm_service=swarm_service_name) 138 | self.monitored_containers[container.id] = container 139 | continue 140 | if container.name in self.selected_containers: 141 | self._monitor_container(container) 142 | self.monitored_containers[container.id] = container 143 | self._watch_events() 144 | self._start_message() 145 | 146 | def reload_config(self, config): 147 | """ 148 | This function is called from the ConfigHandler class in app.py when the config.yaml file changes. 149 | It starts monitoring new containers that are in the config and stops monitoring containers that are no longer in the config. 150 | The keywords and other settings are updated in the line processor instances. 151 | The function can also get called when there is not connection to the docker host. Then the config is updated but the changes are not applied. 152 | When LoggiFly reconnects to the docker host it calls this function with config=None to apply the changes. 153 | """ 154 | if self.swarm_mode: 155 | self.logger.debug("Skipping config reload because of Swarm Mode") 156 | return 157 | self.config = config if config is not None else self.config 158 | self.logger.setLevel(getattr(logging, self.config.settings.log_level.upper(), logging.INFO)) 159 | self.selected_containers = [c for c in self.config.containers] 160 | if self.shutdown_event.is_set(): 161 | self.logger.debug("Shutdown event is set. Not applying config changes.") 162 | return 163 | try: 164 | # stop monitoring containers that are no longer in the config 165 | stop_monitoring = [c for _, c in self.monitored_containers.items() if c.name not in self.selected_containers] 166 | for c in stop_monitoring: 167 | container_stop_event = self.line_processor_instances[c.name]["container_stop_event"] 168 | self._close_stream_connection(c.name) 169 | self.monitored_containers.pop(c.id) 170 | # reload config variables in the line processor instances to update keywords and other settings 171 | for container in self.line_processor_instances.keys(): 172 | processor = self.line_processor_instances[container]["processor"] 173 | processor.load_config_variables(self.config) 174 | # start monitoring new containers that are in the config but not monitored yet 175 | containers_to_monitor = [c for c in self.client.containers.list() if c.name in self.selected_containers and c.id not in self.monitored_containers.keys()] 176 | for c in containers_to_monitor: 177 | self.logger.info(f"New Container to monitor: {c.name}") 178 | if self.line_processor_instances.get(c.name) is not None: 179 | container_stop_event = self.line_processor_instances[c.name]["container_stop_event"] 180 | container_stop_event.clear() 181 | self._monitor_container(c) 182 | self.monitored_containers[c.id] = c 183 | 184 | self._start_message(config_reload=True) 185 | except Exception as e: 186 | self.logger.error(f"Error handling config changes: {e}") 187 | 188 | def _start_message(self, config_reload=False): 189 | monitored_containers_message = "\n - ".join(c.name for id, c in self.monitored_containers.items()) 190 | unmonitored_containers = [c for c in self.selected_containers if c not in [c.name for id, c in self.monitored_containers.items()]] 191 | message = (f"These containers are being monitored:\n - {monitored_containers_message}" if self.monitored_containers 192 | else f"No selected containers are running. Waiting for new containers...") 193 | message = message + ((f"\n\nThese selected containers are not running:\n - " + '\n - '.join(unmonitored_containers)) if unmonitored_containers else "") 194 | if self.swarm_mode: 195 | monitored_swarm_services = [] 196 | for c in self.monitored_containers.values(): 197 | swarm_label = self._check_if_swarm_to_monitor(c) 198 | if swarm_label: 199 | monitored_swarm_services.append(swarm_label) 200 | unmonitored_swarm_services = [s for s in self.selected_swarm_services if s not in monitored_swarm_services] 201 | message = message + ((f"\n\nThese selected Swarm Services are not running:\n - " + '\n - '.join(unmonitored_swarm_services)) if unmonitored_swarm_services else "") 202 | 203 | title = f"LoggiFly: The config file was reloaded" if config_reload else f"LoggiFly started" 204 | 205 | self.logger.info(title + "\n" + message) 206 | if ((self.config.settings.disable_start_message is False and config_reload is False) 207 | or (config_reload is True and self.config.settings.disable_config_reload_message is False)): 208 | send_notification(self.config, 209 | container_name="LoggiFly", 210 | title=title, 211 | hostname=self.hostname, 212 | message=message 213 | ) 214 | 215 | def _handle_error(self, error_count, last_error_time, container_name=None): 216 | """ 217 | Error handling for the event handler and the log stream threads. 218 | If there are too many errors the thread is stopped 219 | and if the client can not be reached by pinging it the whole monitoring process stops for this host by calling the cleanup function. 220 | """ 221 | MAX_ERRORS = 5 222 | ERROR_WINDOW = 60 223 | now = time.time() 224 | error_count = 0 if now - last_error_time > ERROR_WINDOW else error_count + 1 225 | last_error_time = now 226 | 227 | if error_count > MAX_ERRORS: 228 | if container_name: 229 | self.logger.error(f"Too many errors for {container_name}. Count: {error_count}") 230 | else: 231 | self.logger.error(f"Too many errors for Docker Event Watcher. Count: {error_count}") 232 | disconnected = False 233 | try: 234 | if not self.client.ping(): 235 | disconnected = True 236 | except Exception as e: 237 | logging.error(f"Error while trying to ping Docker Host {self.host}: {e}") 238 | disconnected = True 239 | if disconnected and not self.shutdown_event.is_set(): 240 | self.logger.error(f"Connection lost to Docker Host {self.host} ({self.hostname if self.hostname else ''}).") 241 | self.cleanup(timeout=30) 242 | return error_count, last_error_time, True # True = to_many_errors (break while loop) 243 | 244 | time.sleep(random.uniform(0.9, 1.2) * error_count) # to prevent all threads from trying to reconnect at the same time 245 | return error_count, last_error_time, False 246 | 247 | def _monitor_container(self, container, swarm_service=None): 248 | def check_container(container_start_time, error_count): 249 | """ 250 | Check if the container is still running and whether it is still the same container (by comparing the initial start time with the current one). 251 | This is done to stop the monitoring when the container is not running and to get rid of old threads 252 | """ 253 | try: 254 | container.reload() 255 | if container.status != "running": 256 | self.logger.debug(f"Container {container.name} is not running. Stopping monitoring.") 257 | return False 258 | if container.attrs['State']['StartedAt'] != container_start_time: 259 | self.logger.debug(f"Container {container.name} was restarted. Stopping monitoring.") 260 | return False 261 | except docker.errors.NotFound: 262 | self.logger.error(f"Container {container.name} not found during container check. Stopping monitoring.") 263 | return False 264 | except requests.exceptions.ConnectionError as ce: 265 | if error_count == 1: 266 | self.logger.error(f"Can not connect to Container {container.name} {ce}") 267 | 268 | except Exception as e: 269 | if error_count == 1: 270 | self.logger.error(f"Error while checking container {container.name}: {e}") 271 | return True 272 | 273 | def log_monitor(): 274 | """ 275 | Streams the logs of one container and gives every line to a processor instance of LogProcessor (from line_processor.py). 276 | The processor processes the line, searches for keywords, sends notifications, etc. 277 | I am using a buffer in case the logstream has an unfinished log line that can't be decoded correctly. 278 | """ 279 | container_start_time = container.attrs['State']['StartedAt'] 280 | self.logger.info(f"Monitoring for Container started: {container.name}") 281 | error_count, last_error_time = 0, time.time() 282 | too_many_errors = False 283 | 284 | # re-use old line processor instance if it exists, otherwise create a new one 285 | if container.name in self.line_processor_instances: 286 | self.logger.debug(f"{container.name}: Re-Using old line processor") 287 | processor, container_stop_event = self.line_processor_instances[container.name]["processor"], self.line_processor_instances[container.name]["container_stop_event"] 288 | processor._start_flush_thread() 289 | else: 290 | container_stop_event = threading.Event() 291 | processor = LogProcessor(self.logger, self.hostname, self.config, container, container_stop_event, swarm_service=swarm_service) 292 | self._add_processor_instance(processor, container_stop_event, container.name) 293 | 294 | container_stop_event.clear() 295 | while not self.shutdown_event.is_set() and not container_stop_event.is_set(): 296 | buffer = b"" 297 | try: 298 | now = datetime.now() 299 | not_found_error = False 300 | log_stream = container.logs(stream=True, follow=True, since=now) 301 | self._add_stream_connection(container.name, log_stream) 302 | self.logger.info(f"{container.name}: Log Stream started") 303 | for chunk in log_stream: 304 | MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10MB 305 | buffer += chunk 306 | if len(buffer) > MAX_BUFFER_SIZE: 307 | self.logger.error(f"{container.name}: Buffer overflow detected for container, resetting") 308 | buffer = b"" 309 | while b'\n' in buffer: 310 | line, buffer = buffer.split(b'\n', 1) 311 | try: 312 | log_line_decoded = str(line.decode("utf-8")).strip() 313 | except UnicodeDecodeError: 314 | log_line_decoded = line.decode("utf-8", errors="replace").strip() 315 | self.logger.warning(f"{container.name}: Error while trying to decode a log line. Used errors='replace' for line: {log_line_decoded}") 316 | if log_line_decoded: 317 | processor.process_line(log_line_decoded) 318 | except docker.errors.NotFound as e: 319 | self.logger.error(f"Container {container} not found during Log Stream: {e}") 320 | not_found_error = True 321 | except Exception as e: 322 | error_count, last_error_time, too_many_errors = self._handle_error(error_count, last_error_time, container.name) 323 | if error_count == 1: # log error only once 324 | self.logger.error("Error trying to monitor %s: %s", container.name, e) 325 | self.logger.debug(traceback.format_exc()) 326 | finally: 327 | if self.shutdown_event.is_set() or too_many_errors or not_found_error: 328 | break 329 | elif container_stop_event.is_set() or not check_container(container_start_time, error_count): 330 | self._close_stream_connection(container.name) 331 | break 332 | else: 333 | self.logger.info(f"{container.name}: Log Stream stopped. Reconnecting... {'error count: ' + str(error_count) if error_count > 0 else ''}") 334 | self.logger.info(f"{container.name}: Monitoring stopped for container.") 335 | container_stop_event.set() 336 | 337 | thread = threading.Thread(target=log_monitor, daemon=True) 338 | self._add_thread(thread) 339 | thread.start() 340 | 341 | 342 | def _watch_events(self): 343 | """ 344 | When a container is started that is set in the config the monitor_container function is called to start monitoring it. 345 | When a selected container is stopped the stream connection is closed (causing the threads associated with it to stop) 346 | and the container is removed from the monitored containers. 347 | """ 348 | def event_handler(): 349 | error_count = 0 350 | last_error_time = time.time() 351 | while not self.shutdown_event.is_set(): 352 | now = time.time() 353 | too_many_errors = False 354 | try: 355 | event_stream = self.client.events(decode=True, filters={"event": ["start", "stop"]}, since=now) 356 | self.logger.info("Docker Event Watcher started. Watching for new containers...") 357 | for event in event_stream: 358 | if self.shutdown_event.is_set(): 359 | self.logger.debug("Shutdown event is set. Stopping event handler.") 360 | break 361 | container_id = event["Actor"]["ID"] 362 | if event.get("Action") == "start": 363 | container = self.client.containers.get(container_id) 364 | swarm_label = self._check_if_swarm_to_monitor(container) if self.swarm_mode else None 365 | if swarm_label or container.name in self.selected_containers: 366 | self._monitor_container(container, swarm_service=swarm_label) 367 | self.logger.info(f"Monitoring new container: {container.name}") 368 | if self.config.settings.disable_container_event_message is False: 369 | send_notification(self.config, "Loggifly", "LoggiFly", f"Monitoring new container: {container.name}", hostname=self.hostname) 370 | self.monitored_containers[container.id] = container 371 | elif event.get("Action") == "stop": 372 | if container_id in self.monitored_containers: 373 | container = self.monitored_containers.get(container_id) 374 | self.logger.info(f"The Container {container.name} was stopped. Stopping Monitoring now.") 375 | self.monitored_containers.pop(container_id) 376 | self._close_stream_connection(container.name) 377 | # else: 378 | # self.logger.debug(f'Docker Event Watcher: {event["Actor"]["Attributes"].get("name", container_id)} was stopped. Ignoring because it is not monitored') 379 | except docker.errors.NotFound as e: 380 | self.logger.error(f"Docker Event Handler: Container {container} not found: {e}") 381 | except Exception as e: 382 | error_count, last_error_time, too_many_errors = self._handle_error(error_count, last_error_time) 383 | if error_count == 1: 384 | self.logger.error(f"Docker Event-Handler was stopped {e}. Trying to restart it.") 385 | finally: 386 | if self.shutdown_event.is_set() or too_many_errors: 387 | self.logger.debug("Docker Event Watcher is shutting down.") 388 | break 389 | else: 390 | self.logger.info(f"Docker Event Watcher stopped. Reconnecting... {'error count: ' + str(error_count) if error_count > 0 else ''}") 391 | self.logger.info("Docker Event Watcher stopped.") 392 | 393 | thread = threading.Thread(target=event_handler, daemon=True) 394 | self._add_thread(thread) 395 | thread.start() 396 | 397 | def cleanup(self, timeout=1.5): 398 | """ 399 | This function is called when the program is shutting down or when there are too many errors and the client is not reachable. 400 | By closing the stream connections the log stream which would otherwise be blocked until the next log line gets released allowing the threads to fninish. 401 | The only thread that can not easily be stopped from outside is the event_handler, because it is is blocked until the next event. 402 | That's not really a problem because when the connection is lost the event handler does stop and when the container shuts down it doesn't matter that much that one thread was still running 403 | """ 404 | self.logger.info(f"Starting cleanup " f"for host {self.hostname}..." if self.hostname else "...") 405 | self.cleanup_event.set() 406 | self.shutdown_event.set() 407 | with self.stream_connections_lock: 408 | for container, _ in self.stream_connections.copy().items(): 409 | self._close_stream_connection(container) 410 | 411 | with self.threads_lock: 412 | alive_threads = [] 413 | for thread in self.threads: 414 | if thread is not threading.current_thread() and thread.is_alive(): 415 | thread.join(timeout=timeout) 416 | if thread.is_alive(): 417 | self.logger.debug(f"Thread {thread.name} was not stopped") 418 | alive_threads.append(thread) 419 | self.threads = alive_threads 420 | try: 421 | self.client.close() 422 | self.logger.info("Shutdown completed") 423 | except Exception as e: 424 | self.logger.warning(f"Error while trying do close docker client connection during cleanup: {e}") 425 | 426 | self.cleanup_event.clear() 427 | # self.logger.debug(f"Threads still alive {len(alive_threads)}: {alive_threads}") 428 | # self.logger.debug(f"Threading Enumerate: {threading.enumerate()}") -------------------------------------------------------------------------------- /app/line_processor.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import os 3 | import re 4 | import time 5 | import json 6 | import string 7 | import logging 8 | import traceback 9 | import threading 10 | from threading import Thread, Lock 11 | from notifier import send_notification 12 | from load_config import GlobalConfig 13 | 14 | class LogProcessor: 15 | """ 16 | This class processes log lines from a Docker container and: 17 | - searches for patterns and keywords, 18 | - tries to catch entries that span multple lines by detecting patterns and putting lines in a buffer first to see if the next line belongs to the same entry or not 19 | - triggers notifications when a keyword is found 20 | - triggers restarts/stops of the monitored container 21 | 22 | 23 | LoggiFly searches for patterns that signal the start of a log entry to detect entries that span over multiple lines. 24 | That is what these patterns are for: 25 | """ 26 | STRICT_PATTERNS = [ 27 | 28 | # combined timestamp and log level 29 | r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:,\d{3})?\] \[(?:INFO|ERROR|DEBUG|WARN|WARNING|CRITICAL)\]", # 30 | r"^\d{4}-\d{2}-\d{2}(?:, | )\d{2}:\d{2}:\d{2}(?:,\d{3})? (?:INFO|ERROR|DEBUG|WARN|WARNING|CRITICAL)", 31 | 32 | # ISO in brackets 33 | r"^\[\d{4}-\d{2}-\d{2}(?:T|, | )\d{2}:\d{2}:\d{2}(?:Z|[\.,]\d{2,6}|[+-]\d{2}:\d{2}| [+-]\d{4})\]", # [2025-02-17T03:23:07Z] or [2025-02-17 04:22:59 +0100] or [2025-02-18T03:23:05.436627] 34 | 35 | # Months in brackets 36 | r"^\[(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2}, \d{4} \d{2}:\d{2}:\d{2}\]", # [Feb 17, 2025 10:13:02] 37 | r"^\[\d{1,2}\/(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\/\d{4}(?:\:| |\/)\d{2}:\d{2}:\d{2}(?:Z||\s[+\-]\d{2}:\d{2}|\s[+\-]\d{4})\]", # [17/Feb/2025:10:13:02 +0000] 38 | 39 | # ISO without brackes 40 | r"^\b\d{4}-\d{2}-\d{2}(?:T|, | )\d{2}:\d{2}:\d{2}(?:Z|[\.,]\d{2,6}|[+-]\d{2}:\d{2}| [+-]\d{4})\b", 41 | 42 | # Months without brackets 43 | r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2}, \d{4} \d{2}:\d{2}:\d{2}\b", 44 | r"\b\d{1,2}\/(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\/\d{4}(?:\:| |\/)\d{2}:\d{2}:\d{2}(?:Z||\s[+\-]\d{2}:\d{2}|\s[+\-]\d{4})\b", # 17/Feb/2025:10:13:02 +0000 45 | 46 | # Unix-like Timestamps 47 | r"^\[\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}\.\d{2,6}\]", 48 | 49 | # Log-Level at the beginning of the line 50 | r"^\[(?:INFO|ERROR|DEBUG|WARN|WARNING|CRITICAL)\]", 51 | r"^\((?:INFO|ERROR|DEBUG|WARN|WARNING|CRITICAL)\)" 52 | ] 53 | 54 | FLEX_PATTERNS = [ 55 | # ---------------------------------------------------------------- 56 | # Generic Timestamps (Fallback) 57 | # ---------------------------------------------------------------- 58 | 59 | r"\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\b", 60 | r"\b\d{4}-\d{2}-\d{2}(?:T|, | )\d{2}:\d{2}:\d{2}(?:Z|[\.,]\d{2,6}|[+-]\d{2}:\d{2}| [+-]\d{4})\b", # 2025-02-17T03:23:07Z 61 | r"\b(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])-\d{4} \d{2}:\d{2}:\d{2}\b", 62 | r"(?i)\b\d{2}\/\d{2}\/\d{4}(?:,\s+|:|\s+])\d{1,2}:\d{2}:\d{2}\s*(?:AM|PM)?\b", 63 | r"\b\d{10}\.\d+\b", # 1739762586.0394847 64 | 65 | # ---------------------------------------------------------------- 66 | # Log-Level (Fallback) 67 | # ---------------------------------------------------------------- 68 | r"(?i)(?<=^)\b(?:INFO|ERROR|DEBUG|WARN(?:ING)?|CRITICAL)\b(?=\s|:|$)", 69 | r"(?i)(?<=\s)\b(?:INFO|ERROR|DEBUG|WARN(?:ING)?|CRITICAL)\b(?=\s|:|$)", 70 | r"(?i)\[(?:INFO|ERROR|DEBUG|WARN(?:ING)?|CRITICAL)\]", 71 | r"(?i)\((?:INFO|ERROR|DEBUG|WARN(?:ING)?|CRITICAL)\)", 72 | r"(?i)\d{2}/\d{2}/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(?:AM|PM)", 73 | ] 74 | 75 | COMPILED_STRICT_PATTERNS = [re.compile(pattern, re.ASCII) for pattern in STRICT_PATTERNS] 76 | COMPILED_FLEX_PATTERNS = [re.compile(pattern, re.ASCII) for pattern in FLEX_PATTERNS] 77 | 78 | def __init__(self, logger, hostname, config: GlobalConfig, container, container_stop_event, swarm_service=None): 79 | self.logger = logger 80 | self.hostname = hostname # empty string if only one client else the hostname of the client 81 | self.container_stop_event = container_stop_event 82 | self.container = container 83 | self.swarm_service=swarm_service 84 | self.container_name = self.swarm_service if self.swarm_service else self.container.name 85 | 86 | self.patterns = [] 87 | self.patterns_count = {pattern: 0 for pattern in self.__class__.COMPILED_STRICT_PATTERNS + self.__class__.COMPILED_FLEX_PATTERNS} 88 | self.lock_buffer = Lock() 89 | self.flush_thread_stopped = threading.Event() 90 | self.flush_thread_stopped.set() 91 | self.waiting_for_pattern = False 92 | self.valid_pattern = False 93 | 94 | self.load_config_variables(config) 95 | 96 | def load_config_variables(self, config): 97 | """ 98 | This function can get called from the log_monitor function in app.py to reload the config variables 99 | """ 100 | self.config = config 101 | self.container_keywords = self.config.global_keywords.keywords.copy() 102 | self.container_keywords_with_attachment = self.config.global_keywords.keywords_with_attachment.copy() 103 | 104 | if self.swarm_service: 105 | self.container_keywords.extend(keyword for keyword in self.config.swarm_services[self.swarm_service].keywords if keyword not in self.container_keywords) 106 | self.container_keywords_with_attachment.extend(keyword for keyword in self.config.swarm_services[self.swarm_service].keywords_with_attachment if keyword not in self.container_keywords_with_attachment) 107 | self.container_action_keywords = [] 108 | 109 | self.lines_number_attachment = self.config.swarm_services[self.swarm_service].attachment_lines or self.config.settings.attachment_lines 110 | self.notification_cooldown = self.config.swarm_services[self.swarm_service].notification_cooldown or self.config.settings.notification_cooldown 111 | self.action_cooldown = self.config.swarm_services[self.swarm_service].action_cooldown or self.config.settings.action_cooldown or 300 112 | self.notification_title = self.config.swarm_services[self.swarm_service].notification_title or self.config.settings.notification_title 113 | else: 114 | self.container_keywords.extend(keyword for keyword in self.config.containers[self.container_name].keywords if keyword not in self.container_keywords) 115 | self.container_keywords_with_attachment.extend(keyword for keyword in self.config.containers[self.container_name].keywords_with_attachment if keyword not in self.container_keywords_with_attachment) 116 | self.container_action_keywords = [keyword for keyword in self.config.containers[self.container_name].action_keywords] 117 | 118 | self.lines_number_attachment = self.config.containers[self.container_name].attachment_lines or self.config.settings.attachment_lines 119 | self.notification_cooldown = self.config.containers[self.container_name].notification_cooldown or self.config.settings.notification_cooldown 120 | self.action_cooldown = self.config.containers[self.container_name].action_cooldown or self.config.settings.action_cooldown or 300 121 | self.notification_title = self.config.containers[self.container_name].notification_title or self.config.settings.notification_title 122 | 123 | self.multi_line_config = self.config.settings.multi_line_entries 124 | self.time_per_keyword = {} 125 | self.last_action_time = None 126 | 127 | for keyword in self.container_keywords + self.container_keywords_with_attachment: 128 | if isinstance(keyword, dict) and keyword.get("regex") is not None: 129 | self.time_per_keyword[keyword["regex"]] = 0 130 | elif isinstance(keyword, dict) and keyword.get("keyword") is not None: 131 | self.time_per_keyword[keyword["keyword"]] = 0 132 | else: 133 | self.time_per_keyword[keyword] = 0 134 | 135 | if self.multi_line_config is True: 136 | self.line_count = 0 137 | self.line_limit = 300 138 | if self.valid_pattern is False: 139 | try: 140 | log_tail = self.container.logs(tail=100).decode("utf-8") 141 | self._find_pattern(log_tail) 142 | except Exception as e: 143 | self.logger.error(f"Could not read logs of Container {self.container_name}: {e}") 144 | if self.valid_pattern: 145 | self.logger.debug(f"{self.container_name}: Mode: Multi-Line. Found starting pattern(s) in logs.") 146 | else: 147 | self.logger.debug(f"{self.container_name}: Mode: Single-Line. Could not find starting pattern in the logs. Continuing the search in the next {self.line_limit - self.line_count} lines") 148 | 149 | self.buffer = [] 150 | self.log_stream_timeout = 1 # self.config.settings.flush_timeout Not an supported setting (yet) 151 | self.log_stream_last_updated = time.time() 152 | # Start Background-Thread for Timeout (only starts when it is not already running) 153 | self._start_flush_thread() 154 | 155 | def process_line(self, line): 156 | """ 157 | This function gets called from outside this class by the monitor_container_logs function in app.py 158 | If the user disables multi_line_entries or if there are no patterns detected (yet) the program switches to single-line mode 159 | In single-line mode the line gets processed and searched for keywords instantly instead of going into the buffer first 160 | """ 161 | clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) 162 | if self.multi_line_config == False: 163 | self._search_and_send(clean_line) 164 | else: 165 | if self.line_count < self.line_limit: 166 | self._find_pattern(clean_line) 167 | if self.valid_pattern == True: 168 | self._process_multi_line(clean_line) 169 | else: 170 | self._search_and_send(clean_line) 171 | 172 | def _find_pattern(self, line_s): 173 | """ 174 | searches for patterns in the log lines to be able to detect the start of new log entries. 175 | When a pattern is found self.valid_pattern is set to True and the pattern gets added to self.patterns. 176 | When no pattern is found self.valid_pattern stays False which sets the mode to single-line (not catching multi line entries). 177 | """ 178 | self.waiting_for_pattern = True 179 | for line in line_s.splitlines(): 180 | clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) 181 | self.line_count += 1 182 | for pattern in self.__class__.COMPILED_STRICT_PATTERNS: 183 | if pattern.search(clean_line): 184 | self.patterns_count[pattern] += 1 185 | break 186 | else: 187 | for pattern in self.__class__.COMPILED_FLEX_PATTERNS: 188 | if pattern.search(clean_line): 189 | self.patterns_count[pattern] += 1 190 | break 191 | 192 | sorted_patterns = sorted(self.patterns_count.items(), key=lambda x: x[1], reverse=True) 193 | threshold = max(5, int(self.line_count * 0.075)) 194 | 195 | for pattern in sorted_patterns: 196 | if pattern[0] not in self.patterns and pattern[1] > threshold: 197 | self.patterns.append(pattern[0]) 198 | self.logger.debug(f"{self.container_name}: Found pattern: {pattern[0]} with {pattern[1]} matches of {self.line_count} lines. {round(pattern[1] / self.line_count * 100, 2)}%") 199 | self.valid_pattern = True 200 | if self.patterns == []: 201 | self.valid_pattern = False 202 | if self.line_count >= self.line_limit: 203 | if self.patterns == []: 204 | self.logger.info(f"{self.container_name}: No pattern found in logs after {self.line_limit} lines. Mode: single-line") 205 | 206 | self.waiting_for_pattern = False 207 | 208 | def _start_flush_thread(self): 209 | def check_flush(): 210 | """ 211 | When mode is multi-line new lines go into a buffer first to see whether the next line belongs to the same entry or not. 212 | Every second the buffer gets flushed 213 | """ 214 | self.flush_thread_stopped.clear() 215 | while True: 216 | if self.container_stop_event.is_set(): # self.shutdown_event.is_set() or 217 | time.sleep(4) 218 | if self.container_stop_event.is_set(): 219 | break 220 | if self.multi_line_config is False: 221 | break 222 | with self.lock_buffer: 223 | if (time.time() - self.log_stream_last_updated > self.log_stream_timeout) and self.buffer: 224 | self._handle_and_clear_buffer() 225 | time.sleep(1) 226 | self.flush_thread_stopped.set() 227 | self.logger.debug(f"Flush Thread stopped for Container {self.container_name}") 228 | 229 | if self.flush_thread_stopped.is_set(): 230 | self.flush_thread = Thread(target=check_flush, daemon=True) 231 | self.flush_thread.start() 232 | 233 | def _handle_and_clear_buffer(self): 234 | """ 235 | This function is called either when the buffer is flushed 236 | or when a new log entry was found that does not belong to the last line in the buffer 237 | It calls the _search_and_send function to search for keywords in all log lines in the buffer 238 | """ 239 | log_entry = "\n".join(self.buffer) 240 | self._search_and_send(log_entry) 241 | self.buffer.clear() 242 | 243 | def _process_multi_line(self, line): 244 | """ 245 | When mode is multi-line this function processes the log lines. 246 | It checks if the line matches any of the patterns (meaning the line signals a new log entry) 247 | and if so, it flushes the buffer and appends the new line to the buffer. 248 | """ 249 | # When the pattern gets updated by _find_pattern() this function waits 250 | while self.waiting_for_pattern is True: 251 | time.sleep(1) 252 | 253 | for pattern in self.patterns: 254 | # If there is a pattern in the line idicating a new log entry the buffer gets flushed and the line gets appended to the buffer 255 | if pattern.search(line): 256 | if self.buffer: 257 | self._handle_and_clear_buffer() 258 | self.buffer.append(line) 259 | match = True 260 | break 261 | else: 262 | match = False 263 | # If the line is not a new entry (no pattern was found) it gets appended to the buffer 264 | if match is False: 265 | if self.buffer: 266 | self.buffer.append(line) 267 | else: 268 | # Fallback: Unexpected Format 269 | self.buffer.append(line) 270 | self.log_stream_last_updated = time.time() 271 | 272 | def _search_keyword(self, log_line, keyword, ignore_keyword_time=False): 273 | """ 274 | searches for keywords and regex patterns in the log entry it is given- 275 | When a keywords is found and the time since the last notification is greater than the cooldown time 276 | it returns the keywords, if nothing is found it returns None 277 | """ 278 | if isinstance(keyword, dict): 279 | if keyword.get("regex") is not None: 280 | regex_keyword = keyword["regex"] 281 | if ignore_keyword_time or time.time() - self.time_per_keyword.get(regex_keyword) >= int(self.notification_cooldown): 282 | match = re.search(regex_keyword, log_line, re.IGNORECASE) 283 | if match: 284 | self.time_per_keyword[regex_keyword] = time.time() 285 | return "Regex-Pattern" if keyword.get("hide_pattern_in_title", "").strip().lower() == "true" else f"Regex: {regex_keyword}" 286 | elif keyword.get("keyword") is not None: 287 | keyword = str(keyword["keyword"]) 288 | if isinstance(keyword, str): 289 | if ignore_keyword_time or time.time() - self.time_per_keyword.get(keyword) >= int(self.notification_cooldown): 290 | if keyword.lower() in log_line.lower(): 291 | self.time_per_keyword[keyword] = time.time() 292 | return keyword 293 | return None 294 | 295 | def _message_from_template(self, keyword, log_line): 296 | message = log_line 297 | 298 | if keyword.get("json_template") is not None: 299 | template = keyword.get("json_template") 300 | try: 301 | json_log_entry = json.loads(log_line) 302 | json_template_fields = [f for _, f, _, _ in string.Formatter().parse(template) if f] 303 | json_log_data = {k: json_log_entry.get(k, "") for k in json_template_fields} 304 | json_log_data["original_log_line"] = log_line 305 | message = template.format(**json_log_data) 306 | self.logger.debug(f"Successfully applied this template: {template}") 307 | except (json.JSONDecodeError, UnicodeDecodeError): 308 | self.logger.error(f"Error parsing log line as JSON: {log_line}") 309 | except KeyError as e: 310 | self.logger.error(f"KeyError: {e} in template: {template} with log line: {log_line}") 311 | except Exception as e: 312 | self.logger.error(f"Unexpected Error trying to parse a JSON log line with template {template}: {e}") 313 | self.logger.error(f"Details: {traceback.format_exc()}") 314 | 315 | elif keyword.get("template") is not None and "regex" in keyword: 316 | template = keyword.get("template") 317 | match = re.search(keyword["regex"], log_line, re.IGNORECASE) 318 | if match: 319 | groups = match.groupdict() 320 | groups.setdefault("original_log_line", log_line) 321 | try: 322 | message = template.format(**groups) 323 | self.logger.debug(f"Successfully applied this template: {template}") 324 | return message 325 | except KeyError as e: 326 | self.logger.error(f"Key Error for template '{template}': {e}") 327 | except Exception as e: 328 | self.logger.error(f"Error applying template {template}: {e}") 329 | return message 330 | 331 | def _search_and_send(self, log_line): 332 | """ 333 | Triggers the search for keywords, keywords with attachment and action_keywords 334 | and if found calls the _send_message function to send a notification 335 | or the _container_action function to restart/stop the container 336 | """ 337 | keywords_found = [] 338 | template = None 339 | send_attachment = False 340 | message = log_line 341 | # Search for normal keywords 342 | for keyword in self.container_keywords: 343 | found = self._search_keyword(log_line, keyword) 344 | if found: 345 | keywords_found.append(found) 346 | if isinstance(keyword, dict) and (keyword.get("template") is not None or keyword.get("json_template") is not None): 347 | message = self._message_from_template(keyword, log_line) 348 | 349 | # Search for Keywords with attachment 350 | for keyword in self.container_keywords_with_attachment: 351 | found = self._search_keyword(log_line, keyword) 352 | if found: 353 | keywords_found.append(found) 354 | send_attachment = True 355 | if isinstance(keyword, dict) and (keyword.get("template") is not None or keyword.get("json_template") is not None): 356 | message = self._message_from_template(keyword, log_line) 357 | 358 | # Trigger notification if keywords have been found 359 | if keywords_found: 360 | formatted_log_entry ="\n ----- LOG-ENTRY -----\n" + ' | ' + '\n | '.join(log_line.splitlines()) + "\n -----------------------" 361 | self.logger.info(f"The following keywords were found in {self.container_name}: {keywords_found}." 362 | + (f" (A Log FIle will be attached)" if send_attachment else "") 363 | + f"{formatted_log_entry}" 364 | ) 365 | if send_attachment: 366 | self._send_message(message, keywords_found, send_attachment=True) 367 | else: 368 | self._send_message(message, keywords_found, send_attachment=False) 369 | 370 | # Keywords that trigger a restart 371 | for keyword in self.container_action_keywords: 372 | if self.last_action_time is None or (self.last_action_time is not None and time.time() - self.last_action_time >= max(int(self.action_cooldown), 60)): 373 | if isinstance(keyword, dict) and keyword.get("stop") is not None: 374 | action_keyword = keyword.get("stop") 375 | found = self._search_keyword(log_line, action_keyword, ignore_keyword_time=True) 376 | action = ("stop") if found else None 377 | elif isinstance(keyword, dict) and keyword.get("restart") is not None: 378 | action_keyword = keyword.get("restart") 379 | found = self._search_keyword(log_line, action_keyword, ignore_keyword_time=True) 380 | action = ("restart") if found else None 381 | if found: 382 | formatted_log_entry ="\n ----- LOG-ENTRY -----\n" + ' | ' + '\n | '.join(log_line.splitlines()) + "\n -----------------------" 383 | self.logger.info(f"{'Stopping' if action == 'stop' else 'Restarting'} {self.container_name} because {found} was found in {formatted_log_entry}") 384 | self._send_message(log_line, found, send_attachment=False, action=action) 385 | self._container_action(action, log_line, found) 386 | self.last_action_time = time.time() 387 | break 388 | 389 | def _log_attachment(self): 390 | """Tail the last lines of the container logs and save them to a file""" 391 | base_name = f"last_{self.lines_number_attachment}_lines_from_{self.container_name}.log" 392 | folder = "/tmp/" 393 | 394 | def find_available_name(filename, number=1): 395 | """Create different file name with number if it already exists (in case of many notifications at same time)""" 396 | new_name = f"{filename.rsplit('.', 1)[0]}_{number}.log" 397 | path = folder + new_name 398 | if os.path.exists(path): 399 | return find_available_name(filename, number + 1) 400 | return path 401 | 402 | if os.path.exists(base_name): 403 | file_path = find_available_name(base_name) 404 | else: 405 | file_path = folder + base_name 406 | try: 407 | os.makedirs("/tmp", exist_ok=True) 408 | log_tail = self.container.logs(tail=self.lines_number_attachment).decode("utf-8") 409 | with open(file_path, "w") as file: 410 | file.write(log_tail) 411 | logging.debug(f"Wrote file: {file_path}") 412 | return file_path 413 | except Exception as e: 414 | self.logger.error(f"Could not creste log attachment file for Container {self.container_name}: {e}") 415 | return None 416 | 417 | def _send_message(self, message, keywords_found, send_attachment=False, action=None): 418 | """Adapt the notification title and call the send_notification function from notifier.py""" 419 | def get_notification_title(): 420 | if self.notification_title.strip().lower() != "default" and action is None: 421 | template = "" 422 | try: 423 | keywords = ', '.join(f"'{word}'" for word in keywords_found) 424 | template = self.notification_title.strip() 425 | possible_template_fields = {"keywords": keywords, "container": self.container_name} 426 | template_fields = [f for _, f, _, _ in string.Formatter().parse(template) if f] 427 | configured_template_fields = {k: v for k, v in possible_template_fields.items() if k in template_fields} 428 | title = template.format(**configured_template_fields) 429 | return title 430 | except KeyError as e: 431 | self.logger.error(f"Missing key in template: {template}. Template requires keys that weren't provided. Error: {e}") 432 | except Exception as e: 433 | self.logger.error(f"Error trying to apply this template for the notification title: {template} {e}") 434 | 435 | if isinstance(keywords_found, list): 436 | if len(keywords_found) == 1: 437 | keyword = keywords_found[0] 438 | title = f"'{keyword}' found in {self.container_name}" 439 | elif len(keywords_found) == 2: 440 | joined_keywords = ' and '.join(f"'{word}'" for word in keywords_found) 441 | title = f"{joined_keywords} found in {self.container_name}" 442 | elif len(keywords_found) > 2: 443 | joined_keywords = ', '.join(f"'{word}'" for word in keywords_found) 444 | title = f"The following keywords were found in {self.container_name}: {joined_keywords}" 445 | else: 446 | title = f"{self.container_name}: {keywords_found}" 447 | elif isinstance(keywords_found, str): 448 | keyword = keywords_found 449 | else: 450 | title = f"{self.container_name}: {keywords_found}" 451 | if action: 452 | title = f"{'Stopping' if action == 'stop' else 'Restarting'} {self.container_name} because '{keyword}' was found" 453 | return title 454 | 455 | title = get_notification_title() 456 | if send_attachment: 457 | file_path = self._log_attachment() 458 | if file_path and isinstance(file_path, str) and os.path.exists(file_path): 459 | send_notification(self.config, container_name=self.container_name, keywords=keywords_found, message=message, title=title, hostname=self.hostname, file_path=file_path) 460 | if os.path.exists(file_path): 461 | os.remove(file_path) 462 | self.logger.debug(f"The file {file_path} was deleted.") 463 | else: 464 | self.logger.debug(f"The file {file_path} does not exist.") 465 | 466 | else: 467 | send_notification(self.config, container_name=self.container_name, keywords=keywords_found, message=message, title=title, hostname=self.hostname) 468 | 469 | def _container_action(self, action): 470 | try: 471 | if action == "stop": 472 | self.logger.info(f"Stopping Container: {self.container_name}.") 473 | container = self.container 474 | container.stop() 475 | self.logger.info(f"Container {self.container_name} has been stopped") 476 | elif action == "restart": 477 | self.logger.info(f"Restarting Container: {self.container_name}.") 478 | container = self.container 479 | container.stop() 480 | time.sleep(3) 481 | container.start() 482 | self.logger.info(f"Container {self.container_name} has been restarted") 483 | except Exception as e: 484 | self.logger.error(f"Failed to {action} {self.container_name}: {e}") 485 | -------------------------------------------------------------------------------- /app/load_config.py: -------------------------------------------------------------------------------- 1 | 2 | from pydantic import ( 3 | BaseModel, 4 | Field, 5 | field_validator, 6 | model_validator, 7 | ConfigDict, 8 | SecretStr, 9 | ValidationError 10 | ) 11 | from typing import Dict, List, Optional, Union 12 | import os 13 | import logging 14 | import yaml 15 | 16 | logging.getLogger(__name__) 17 | 18 | """ 19 | this may look unnecessarily complicated but I wanted to learn and use pydantic 20 | I didn't manage to use pydantic's integrated environment variable loading 21 | because I needed env to override yaml data and yaml to override default values and I could not get it to work. 22 | So now I first load the yaml config and the environment variables, merge them and then I validate the merged config with pydantic 23 | """ 24 | 25 | class BaseConfigModel(BaseModel): 26 | model_config = ConfigDict(extra="ignore", validate_default=True) 27 | 28 | class KeywordBase(BaseModel): 29 | keywords: List[Union[str, Dict[str, str]]] = [] 30 | keywords_with_attachment: List[Union[str, Dict[str, str]]] = [] 31 | 32 | @model_validator(mode="before") 33 | def int_to_string(cls, values): 34 | for field in ["keywords", "keywords_with_attachment"]: 35 | if field in values and isinstance(values[field], list): 36 | converted = [] 37 | for kw in values[field]: 38 | if isinstance(kw, dict): 39 | keys = list(kw.keys()) 40 | 41 | if "regex" in keys: 42 | if any(key not in ["regex", "template", "json_template", "hide_pattern_in_title"] for key in keys): 43 | logging.warning(f"Ignoring Error in config for {field}: '{kw}'. Only 'json_template', 'template' and 'hide_pattern_in_title' are allowed as additional keys for regex pattern.") 44 | continue 45 | elif "keyword" in keys: 46 | if any(key not in ["keyword", "json_template"] for key in keys): 47 | logging.warning(f"Ignoring Error in config for {field}: '{kw}'. Only 'json_template' and 'hide_pattern_in_title' are allowed as additional keys for 'keyword'.") 48 | continue 49 | else: 50 | logging.warning(f"Ignoring Error in config for {field}: '{kw}'. Only 'keyword' or 'regex' are allowed as keys.") 51 | continue 52 | for key in keys: 53 | if isinstance(kw[key], int): 54 | kw[key] = str(kw[key]) 55 | converted.append(kw) 56 | else: 57 | try: 58 | converted.append(str(kw)) 59 | except ValueError: 60 | logging.warning(f"Ignoring unexpected Error in config for {field}: '{kw}'.") 61 | continue 62 | values[field] = converted 63 | return values 64 | 65 | class ActionKeywords(BaseModel): 66 | action_keywords: List[Union[str, Dict[str, Union[str, Dict[str, str]]]]] = [] 67 | 68 | @field_validator("action_keywords", mode="before") 69 | def convert_int_to_str(cls, value): 70 | allowed_keys = {"restart", "stop"} 71 | converted = [] 72 | for kw in value: 73 | if isinstance(kw, dict): 74 | if any(key not in allowed_keys for key in kw.keys()): 75 | logging.warning(f"Ignoring Error in config for action_keywords: Key not allowed for restart_keywords. Wrong Input: '{kw}'. Allowed Keys: {allowed_keys}.") 76 | continue 77 | for key, val in kw.items(): 78 | if not val: 79 | logging.warning(f"Ignoring Error in config for action_keywords: Wrong Input: '{key}: {val}'.") 80 | continue 81 | # convert Integer to String 82 | if isinstance(val, int): 83 | converted.append({key: str(val)}) 84 | elif isinstance(val, dict): 85 | if val.get("regex"): 86 | # Convert regex-value, if Integer 87 | if isinstance(val["regex"], (int, str)): 88 | converted.append({key: str(val["regex"])}) 89 | else: 90 | logging.warning(f"Ignoring Error in config for action_keywords: Wrong Input: '{key}: {val}' regex keyword is not a valid value.") 91 | else: 92 | logging.warning(f"Ignoring Error in config for action_keywords: Wrong Input: '{key}: {val}'. If you put a dictionary after 'restart'/'stop' only 'regex' is allowed as a key.") 93 | else: 94 | converted.append({key: val}) 95 | else: 96 | logging.warning(f"Ignoring Error in config for action_keywords: Wrong Input: '{kw}'. You have to set a dictionary with 'restart' or 'stop' as key.") 97 | return converted 98 | 99 | 100 | class ContainerConfig(BaseConfigModel, KeywordBase, ActionKeywords): 101 | ntfy_tags: Optional[str] = None 102 | ntfy_topic: Optional[str] = None 103 | ntfy_priority: Optional[int] = None 104 | attachment_lines: Optional[int] = None 105 | notification_cooldown: Optional[int] = None 106 | action_cooldown: Optional[int] = None 107 | notification_title: Optional[str] = None 108 | 109 | 110 | @field_validator("ntfy_priority") 111 | def validate_priority(cls, v): 112 | if isinstance(v, str): 113 | try: 114 | v = int(v) 115 | except ValueError: 116 | pass 117 | if isinstance(v, int): 118 | if not 1 <= int(v) <= 5: 119 | logging.warning(f"Error in config for ntfy_priority. Must be between 1-5, '{v}' is not allowed. Using default: '3'") 120 | return 3 121 | if isinstance(v, str): 122 | options = ["max", "urgent", "high", "default", "low", "min"] 123 | if v not in options: 124 | logging.warning(f"Error in config for ntfy_priority:'{v}'. Only 'max', 'urgent', 'high', 'default', 'low', 'min' are allowed. Using default: '3'") 125 | return 3 126 | return v 127 | 128 | class GlobalKeywords(BaseConfigModel, KeywordBase): 129 | pass 130 | 131 | class NtfyConfig(BaseConfigModel): 132 | url: str = Field(..., description="Ntfy server URL") 133 | topic: str = Field(..., description="Ntfy topic name") 134 | token: Optional[SecretStr] = Field(default=None, description="Optional access token") 135 | username: Optional[str] = Field(default=None, description="Optional username") 136 | password: Optional[SecretStr] = Field(default=None, description="Optional password") 137 | priority: Union[str, int] = 3 # Field(default=3, description="Message priority 1-5") 138 | tags: Optional[str] = Field("kite,mag", description="Comma-separated tags") 139 | 140 | @field_validator("priority", mode="before") 141 | def validate_priority(cls, v): 142 | if isinstance(v, str): 143 | try: 144 | v = int(v) 145 | except ValueError: 146 | pass 147 | if isinstance(v, int): 148 | if not 1 <= int(v) <= 5: 149 | logging.warning(f"Error in config for ntfy.priority. Must be between 1-5, '{v}' is not allowed. Using default: '3'") 150 | return 3 151 | if isinstance(v, str): 152 | options = ["max", "urgent", "high", "default", "low", "min"] 153 | if v not in options: 154 | logging.warning(f"Error in config for ntfy.priority:'{v}'. Only 'max', 'urgent', 'high', 'default', 'low', 'min' are allowed. Using default: '3'") 155 | return 3 156 | return v 157 | 158 | class AppriseConfig(BaseConfigModel): 159 | url: SecretStr = Field(..., description="Apprise compatible URL") 160 | 161 | class WebhookConfig(BaseConfigModel): 162 | url: str 163 | headers: Optional[dict] = Field(default=None) 164 | 165 | class NotificationsConfig(BaseConfigModel): 166 | ntfy: Optional[NtfyConfig] = Field(default=None, validate_default=False) 167 | apprise: Optional[AppriseConfig] = Field(default=None, validate_default=False) 168 | webhook: Optional[WebhookConfig] = Field(default=None, validate_default=False) 169 | 170 | @model_validator(mode="after") 171 | def check_at_least_one(self) -> "NotificationsConfig": 172 | if self.ntfy is None and self.apprise is None and self.webhook is None: 173 | raise ValueError("At least on of these has to be configured: 'apprise' / 'ntfy' / 'webhook'") 174 | return self 175 | 176 | class Settings(BaseConfigModel): 177 | log_level: str = Field("INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR)") 178 | notification_cooldown: int = Field(5, description="Cooldown in seconds for repeated alerts") 179 | notification_title: str = Field("default", description="Set a template for the notification title") 180 | action_cooldown: Optional[int] = Field(300) 181 | attachment_lines: int = Field(20, description="Number of log lines to include in attachments") 182 | multi_line_entries: bool = Field(True, description="Enable multi-line log detection") 183 | disable_start_message: bool = Field(False, description="Disable startup notification") 184 | disable_shutdown_message: bool = Field(False, description="Disable shutdown notification") 185 | disable_config_reload_message: bool = Field(False, description="Disable config reload notification") 186 | disable_container_event_message: bool = Field(False, description="Disable notification on container stops/starts") 187 | reload_config: bool = Field(True, description="Disable config reaload on config change") 188 | 189 | class GlobalConfig(BaseConfigModel): 190 | containers: Optional[Dict[str, ContainerConfig]] = Field(default=None) 191 | swarm_services: Optional[Dict[str, ContainerConfig]] = Field(default=None) 192 | global_keywords: GlobalKeywords 193 | notifications: NotificationsConfig 194 | settings: Settings 195 | 196 | @model_validator(mode="before") 197 | def transform_legacy_format(cls, values): 198 | # Convert list global_keywords format into dict 199 | if isinstance(values.get("global_keywords"), list): 200 | values["global_keywords"] = { 201 | "keywords": values["global_keywords"], 202 | "keywords_with_attachment": [] 203 | } 204 | # Convert list containers to dict format 205 | if isinstance(values.get("containers"), list): 206 | values["containers"] = { 207 | name: {} for name in values["containers"] 208 | } 209 | # Convert list keywords format per container into dict 210 | for container in values.get("containers"): 211 | if isinstance(values.get("containers").get(container), list): 212 | values["containers"][container] = { 213 | "keywords": values["containers"][container], 214 | "keywords_with_attachment": [] 215 | } 216 | elif values.get("containers").get(container) is None: 217 | values["containers"][container] = { 218 | "keywords": [], 219 | "keywords_with_attachment": [] 220 | } 221 | return values 222 | 223 | @model_validator(mode="after") 224 | def check_at_least_one(self) -> "GlobalConfig": 225 | tmp_list = self.global_keywords.keywords + self.global_keywords.keywords_with_attachment 226 | if not tmp_list: 227 | for k in self.containers: 228 | tmp_list.extend(self.containers[k].keywords) 229 | if not tmp_list: 230 | raise ValueError("No keywords configured. You have to set keywords either per container or globally.") 231 | if not self.containers and not self.swarm_services: 232 | raise ValueError("You have to configure at least one container") 233 | return self 234 | 235 | 236 | def format_pydantic_error(e: ValidationError) -> str: 237 | error_messages = [] 238 | for error in e.errors(): 239 | location = ".".join(map(str, error["loc"])) 240 | msg = error["msg"] 241 | msg = msg = msg.split("[")[0].strip() 242 | error_messages.append(f"Field '{location}': {msg}") 243 | return "\n".join(error_messages) 244 | 245 | 246 | def mask_secret_str(data): 247 | if isinstance(data, dict): 248 | return {k: mask_secret_str(v) for k, v in data.items()} 249 | elif isinstance(data, list): 250 | return [mask_secret_str(item) for item in data] 251 | elif isinstance(data, SecretStr): 252 | return "**********" 253 | else: 254 | return data 255 | 256 | 257 | def merge_yaml_and_env(yaml, env_update): 258 | for key, value in env_update.items(): 259 | if isinstance(value, dict) and key in yaml and key != {}: 260 | merge_yaml_and_env(yaml[key],value) 261 | else: 262 | if value is not None : 263 | yaml[key] = value 264 | return yaml 265 | 266 | 267 | def load_config(official_path="/config/config.yaml"): 268 | """ 269 | Load the configuration from a YAML file and environment variables. 270 | The config.yaml is expected in /config/config.yaml or /app/config.yaml (older version) 271 | """ 272 | config_path = None 273 | required_keys = ["containers", "notifications", "settings", "global_keywords"] 274 | yaml_config = None 275 | legacy_path = "/app/config.yaml" 276 | paths = [official_path, legacy_path] 277 | for path in paths: 278 | logging.info(f"Trying path: {path}") 279 | if os.path.isfile(path): 280 | try: 281 | with open(path, "r") as file: 282 | yaml_config = yaml.safe_load(file) 283 | config_path = path 284 | break 285 | except FileNotFoundError: 286 | logging.info(f"Error loading the config.yaml file from {path}") 287 | except yaml.YAMLError as e: 288 | logging.error(f"Error parsing the YAML file: {e}") 289 | except Exception as e: 290 | logging.error(f"Unexpected error loading the config.yaml file: {e}") 291 | else: 292 | logging.info(f"The path {path} does not exist.") 293 | 294 | if yaml_config is None: 295 | logging.warning(f"The config.yaml could not be loaded.") 296 | yaml_config = {} 297 | else: 298 | logging.info(f"The config.yaml file was found in {path}.") 299 | 300 | for key in required_keys: 301 | if key not in yaml_config or yaml_config[key] is None: 302 | yaml_config[key] = {} 303 | """ 304 | -------------------------LOAD ENVIRONMENT VARIABLES--------------------- 305 | """ 306 | env_config = { "notifications": {}, "settings": {}, "global_keywords": {}, "containers": {}} 307 | settings_values = { 308 | "log_level": os.getenv("LOG_LEVEL"), 309 | "attachment_lines": os.getenv("ATTACHMENT_LINES"), 310 | "multi_line_entries": os.getenv("MULTI_LINE_ENTRIES"), 311 | "notification_cooldown": os.getenv("NOTIFICATION_COOLDOWN"), 312 | "notification_title": os.getenv("NOTIFICATION_TITLE"), 313 | "reload_config": False if config_path is None else os.getenv("RELOAD_CONFIG"), 314 | "disable_start_message": os.getenv("DISABLE_START_MESSAGE"), 315 | "disable_restart_message": os.getenv("DISABLE_CONFIG_RELOAD_MESSAGE"), 316 | "disable_shutdown_message": os.getenv("DISABLE_SHUTDOWN_MESSAGE"), 317 | "disable_container_event_message": os.getenv("DISABLE_CONTAINER_EVENT_MESSAGE"), 318 | "action_cooldown": os.getenv("ACTION_COOLDOWN") 319 | } 320 | ntfy_values = { 321 | "url": os.getenv("NTFY_URL"), 322 | "topic": os.getenv("NTFY_TOPIC"), 323 | "token": os.getenv("NTFY_TOKEN"), 324 | "priority": os.getenv("NTFY_PRIORITY"), 325 | "tags": os.getenv("NTFY_TAGS"), 326 | "username": os.getenv("NTFY_USERNAME"), 327 | "password": os.getenv("NTFY_PASSWORD") 328 | } 329 | webhook_values = { 330 | "url": os.getenv("WEBHOOK_URL"), 331 | "headers":os.getenv("WEBHOOK_HEADERS") 332 | } 333 | apprise_values = { 334 | "url": os.getenv("APPRISE_URL") 335 | } 336 | global_keywords_values = { 337 | "keywords": [kw.strip() for kw in os.getenv("GLOBAL_KEYWORDS", "").split(",") if kw.strip()] if os.getenv("GLOBAL_KEYWORDS") else [], 338 | "keywords_with_attachment": [kw.strip() for kw in os.getenv("GLOBAL_KEYWORDS_WITH_ATTACHMENT", "").split(",") if kw.strip()] if os.getenv("GLOBAL_KEYWORDS_WITH_ATTACHMENT") else [], 339 | } 340 | # Fill env_config dict with environment variables if they are set 341 | if os.getenv("CONTAINERS"): 342 | for c in os.getenv("CONTAINERS", "").split(","): 343 | c = c.strip() 344 | env_config["containers"][c] = {} 345 | 346 | if os.getenv("SWARM_SERVICES"): 347 | env_config["swarm_services"] = {} 348 | for s in os.getenv("SWARM_SERVICES", "").split(","): 349 | s = s.strip() 350 | env_config["swarm_services"][s] = {} 351 | 352 | if any(ntfy_values.values()): 353 | env_config["notifications"]["ntfy"] = ntfy_values 354 | yaml_config["notifications"]["ntfy"] = {} if yaml_config["notifications"].get("ntfy") is None else yaml_config["notifications"]["ntfy"] 355 | if apprise_values["url"]: 356 | env_config["notifications"]["apprise"] = apprise_values 357 | yaml_config["notifications"]["apprise"] = {} if yaml_config["notifications"].get("apprise") is None else yaml_config["notifications"]["apprise"] 358 | if webhook_values.get("url"): 359 | env_config["notifications"]["webhook"] = webhook_values 360 | yaml_config["notifications"]["webhook"] = {} if yaml_config["notifications"].get("webhook") is None else yaml_config["notifications"]["webhook"] 361 | 362 | for k, v in global_keywords_values.items(): 363 | if v: 364 | env_config["global_keywords"][k]= v 365 | for key, value in settings_values.items(): 366 | if value is not None: 367 | env_config["settings"][key] = value 368 | # Merge environment variables and yaml config 369 | merged_config = merge_yaml_and_env(yaml_config, env_config) 370 | # Validate the merged configuration with Pydantic 371 | config = GlobalConfig.model_validate(merged_config) 372 | 373 | config_dict = mask_secret_str(config.model_dump(exclude_none=True, exclude_defaults=False, exclude_unset=False)) 374 | yaml_output = yaml.dump(config_dict, default_flow_style=False, sort_keys=False, indent=4) 375 | logging.info(f"\n ------------- CONFIG ------------- \n{yaml_output}\n ----------------------------------") 376 | 377 | return config, config_path 378 | 379 | -------------------------------------------------------------------------------- /app/notifier.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import base64 3 | import logging 4 | import urllib.parse 5 | import json 6 | import apprise 7 | from load_config import GlobalConfig 8 | 9 | logging.getLogger(__name__) 10 | 11 | def get_ntfy_config(config, container_name): 12 | ntfy_config = {} 13 | ntfy_config["url"] = config.notifications.ntfy.url 14 | if container_name in [c for c in config.containers]: 15 | ntfy_config["topic"] = config.containers[container_name].ntfy_topic or config.notifications.ntfy.topic 16 | ntfy_config["tags"] = config.containers[container_name].ntfy_tags or config.notifications.ntfy.tags 17 | ntfy_config["priority"] = config.containers[container_name].ntfy_priority or config.notifications.ntfy.priority 18 | else: 19 | ntfy_config["topic"] = config.notifications.ntfy.topic 20 | ntfy_config["tags"] = config.notifications.ntfy.tags 21 | ntfy_config["priority"] = config.notifications.ntfy.priority 22 | 23 | if config.notifications.ntfy.token: 24 | ntfy_config["authorization"] = f"Bearer {config.notifications.ntfy.token.get_secret_value()}" 25 | elif config.notifications.ntfy.username and config.notifications.ntfy.password: 26 | credentials = f"{config.notifications.ntfy.username}:{config.notifications.ntfy.password.get_secret_value()}" 27 | encoded_credentials = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') 28 | ntfy_config["authorization"] = f"Basic {encoded_credentials}" 29 | return ntfy_config 30 | 31 | 32 | def send_apprise_notification(url, message, title, file_path=None): 33 | apobj = apprise.Apprise() 34 | apobj.add(url) 35 | message = ("This message had to be shortened: \n" if len(message) > 1900 else "") + message[:1900] 36 | try: 37 | if file_path is None: 38 | apobj.notify( 39 | title=title, 40 | body=message, 41 | ) 42 | else: 43 | apobj.notify( 44 | title=title, 45 | body=message, 46 | attach=file_path 47 | ) 48 | logging.info("Apprise-Notification sent successfully") 49 | except Exception as e: 50 | logging.error("Error while trying to send apprise-notification: %s", e) 51 | 52 | 53 | def send_ntfy_notification(ntfy_config, message, title, file_path=None): 54 | message = ("This message had to be shortened: \n" if len(message) > 3900 else "") + message[:3900] 55 | headers = { 56 | "Title": title, 57 | "Tags": f"{ntfy_config['tags']}", 58 | "Icon": "https://raw.githubusercontent.com/clemcer/loggifly/main/images/icon.png", 59 | "Priority": f"{ntfy_config['priority']}" 60 | } 61 | if ntfy_config.get('authorization'): 62 | headers["Authorization"] = f"{ntfy_config.get('authorization')}" 63 | 64 | try: 65 | if file_path: 66 | file_name = file_path.split("/")[-1] 67 | headers["Filename"] = file_name 68 | with open(file_path, "rb") as file: 69 | if len(message) < 199: 70 | response = requests.post( 71 | f"{ntfy_config['url']}/{ntfy_config['topic']}?message={urllib.parse.quote(message)}", 72 | data=file, 73 | headers=headers 74 | ) 75 | else: 76 | response = requests.post( 77 | f"{ntfy_config['url']}/{ntfy_config['topic']}", 78 | data=file, 79 | headers=headers 80 | ) 81 | else: 82 | response = requests.post( 83 | f"{ntfy_config['url']}/{ntfy_config['topic']}", 84 | data=message, 85 | headers=headers 86 | ) 87 | if response.status_code == 200: 88 | logging.info("Ntfy-Notification sent successfully") 89 | else: 90 | logging.error("Error while trying to send ntfy-notification: %s", response.text) 91 | except requests.RequestException as e: 92 | logging.error("Error while trying to connect to ntfy: %s", e) 93 | 94 | 95 | def send_webhook(json_data, url, headers): 96 | try: 97 | response = requests.post( 98 | url=url, 99 | headers=headers, 100 | json=json_data, 101 | timeout=10 102 | ) 103 | if response.status_code == 200: 104 | logging.info(f"Webhook sent successfully.") 105 | #logging.debug(f"Webhook Response: {json.dumps(response.json(), indent=2)}") 106 | else: 107 | logging.error("Error while trying to send POST request to custom webhook: %s", response.text) 108 | except requests.RequestException as e: 109 | logging.error(f"Error trying to send webhook to url: {url}, headers: {headers}: %s", e) 110 | 111 | 112 | def send_notification(config: GlobalConfig, container_name, title, message, keywords=None, hostname=None, file_path=None): 113 | message = message.replace(r"\n", "\n").strip() 114 | # When multiple hosts are set the hostname is added to the title, when only one host is set the hostname is an empty string 115 | title = f"[{hostname}] - {title}" if hostname else title 116 | 117 | if (config.notifications and config.notifications.ntfy and config.notifications.ntfy.url and config.notifications.ntfy.topic): 118 | ntfy_config = get_ntfy_config(config, container_name) 119 | send_ntfy_notification(ntfy_config, message=message, title=title, file_path=file_path) 120 | 121 | if (config.notifications and config.notifications.apprise and config.notifications.apprise.url): 122 | apprise_url = config.notifications.apprise.url.get_secret_value() 123 | send_apprise_notification(apprise_url, message=message, title=title, file_path=file_path) 124 | 125 | if (config.notifications and config.notifications.webhook and config.notifications.webhook.url): 126 | json_data = {"container": container_name, "keywords": keywords, "title": title, "message": message, "host": hostname} 127 | webhook_url = config.notifications.webhook.url 128 | webhook_headers = config.notifications.webhook.headers 129 | send_webhook(json_data, webhook_url, webhook_headers) 130 | -------------------------------------------------------------------------------- /config_example.yaml: -------------------------------------------------------------------------------- 1 | 2 | ###### CONFIG EXAMPLE ###### 3 | # 4 | # This is an example config.yaml file for loggifly. 5 | # 6 | # If you want to use this file after editing it make sure to rename it to config.yaml 7 | # 8 | # Feel free to contribute to the containers section of this example config with use cases you have found helpful :) 9 | 10 | containers: 11 | audiobookshelf: 12 | ntfy_topic: abs 13 | ntfy_tags: books, headphones 14 | notification_cooldown: 5 15 | notification_title: "{container}" # hide found keywords from notification title 16 | keywords: 17 | # Here are some custom templates: 18 | 19 | # user requested download: 20 | - regex: '(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}).*User "(?P[A-Za-z\s]+)" requested download for item "(?P[A-Za-z\s]+)"' 21 | template: '\n🔎 The user {user} requested download for ''{item}''!\n🕐 {timestamp}' 22 | # user was online 23 | - regex: '(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}).*Socket.*disconnected from client "(?P[A-Za-z\s]+)"' 24 | template: '\n🔎 The user {user} was seen!\n🕐 {timestamp}' 25 | # Failed Login attempt 26 | - regex: '(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}).*Failed login attempt for username "(?P[A-Za-z\s]+)" from ip (?P\d{1,3}(?:\.\d{1,3}){3})\s+\((?P[A-Za-z\s]+)\)' 27 | template: '🚨 Failed login!🙎‍♂️\nUsername: ''{user}''\n🔎 IP Address: {ip_address}\n🕐 {timestamp}' 28 | 29 | - podcast 30 | - regex: User.*logged in # when a user logs in 31 | - failed login # Failed login to the web interface 32 | - Error in openid callback # Error when trying to login with OIDC 33 | 34 | vaultwarden: 35 | ntfy_tags: closed_lock_with_key 36 | ntfy_priority: 5 37 | ntfy_topic: security 38 | notification_cooldown: 0 39 | keywords: 40 | - regex: '(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}).*Username or password is incorrect. Try again. IP: (?P\d{1,3}(?:\.\d{1,3}){3}). Username: (?P[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})' 41 | template: '🚨 Failed login!\n📧 Email: ''{email}''\n🔎 IP Address: {ip_address}\n🕐 {timestamp}' 42 | hide_pattern_in_title: true # Hide full regex pattern in notification title 43 | 44 | ebook2audiobook: 45 | attachment_lines: 300 46 | keywords: 47 | - 100% 48 | - sentence 49 | - converting 50 | keywords_with_attachment: 51 | - total audio parts saved to 52 | 53 | authelia: 54 | keywords: 55 | - regex: \bsuccessful.*authentication 56 | json_template: '{msg}\n🔎 IP: {remote_ip}\n{time}' 57 | - keyword: user not found 58 | json_template: '🚨 Somebody tried to log in with a username that does not exist\n\n🕐{time}\nFull Error Message:\n{msg}' 59 | 60 | adguard: 61 | keywords: 62 | - failed 63 | - error 64 | 65 | kitchenowl: 66 | action_keywords: 67 | - stop: traceback # I have had container restart loops because kitchenowl couldn't connect to my Authentik Server 68 | 69 | global_keywords: 70 | keywords: 71 | - critical 72 | keywords_with_attachment: 73 | - fatal 74 | - panic 75 | 76 | 77 | notifications: 78 | # At least one of these (Ntfy/Apprise/Webhook) is required. 79 | ntfy: 80 | url: http://your-ntfy-server # Required. The URL of your Ntfy instance 81 | topic: loggifly # Required. the topic for Ntfy 82 | token: ntfy-token # Ntfy token in case you need authentication 83 | username: john # Ntfy Username+Password in case you need authentication 84 | password: password # Ntfy Username+Password in case you need authentication 85 | priority: 3 # Ntfy priority (1-5) 86 | tags: kite,mag # Ntfy tags/emojis 87 | apprise: 88 | url: "discord://webhook-url" # Any Apprise-compatible URL (https://github.com/caronc/apprise/wiki) 89 | webhook: 90 | url: https://custom.endpoint.com/post 91 | headers: # add headers if needed 92 | Authorization: "Bearer token" 93 | X-Custom-Header": "Test123" 94 | 95 | # settings are optional because they all have default values 96 | # These are the default settings 97 | settings: 98 | log_level: INFO # DEBUG, INFO, WARNING, ERROR 99 | notification_cooldown: 5 # Seconds between alerts for same keyword (per container) 100 | action_cooldown: 300 # Cooldown period (in seconds) before the next container action can be performed. Maximum is always at least 60s. 101 | notification_title: default # configure a custom template for the notification title (see section below) attachment_lines: 20 # Number of Lines to include in log attachments 102 | multi_line_entries: True # Monitor and catch multi-line log entries instead of going line by line. 103 | reload_config: True # When the config file is changed the program reloads the config 104 | disable_start_message: False # Suppress startup notification 105 | disable_shutdown_message: False # Suppress shutdown notification 106 | disable_config_reload_message: False # Suppress config reload notification 107 | disable_container_event_message: False # Suppress notification when monitoring of containers start/stop 108 | -------------------------------------------------------------------------------- /config_template.yaml: -------------------------------------------------------------------------------- 1 | ####### CONFIG TEMPLATE ####### 2 | # 3 | # This is a template for the config.yaml file. 4 | # You can edit this file and remove all the parts you don't need. 5 | # For the program to function you need to configure: 6 | # - at least one container 7 | # - at least one notification service (Ntfy or Apprise) 8 | # - at least one keyword (either set globally or per container) 9 | # The rest is optional or has default values. 10 | # 11 | # If you want to use this file after editing it make sure to rename it to config.yaml 12 | # 13 | # With every container exaample you can see some more available configuration options 14 | # 15 | # I did not include the option to customize notifications in this template but: 16 | # - Here is a detailed explanation on that: https://github.com/clemcer/loggifly/main#-customize-notifications-templates--log-filtering 17 | # - And here are some examples: https://github.com/clemcer/loggifly/blob/main/config_example.yaml 18 | 19 | containers: 20 | 21 | container1: # leave blank if you only need global keywords 22 | 23 | container2: # must match exact container name 24 | keywords: 25 | - keyword1 26 | - keyword2 27 | 28 | container3: 29 | keywords: 30 | - regex: regex-patern # this is how to set regex patterns 31 | keywords_with_attachment: # attach a logfile to the notification 32 | - keyword2 33 | action_keywords: # trigger a restart/stop of the container. can not be set globally 34 | - restart: keyword3 35 | - stop: keyword4 36 | 37 | container4: 38 | # The next 6 settings override the global values only for this container 39 | ntfy_tags: closed_lock_with_key 40 | ntfy_priority: 5 41 | ntfy_topic: container4 42 | notification_title: '{keywords} found in {container}' # modify the notification title for this container 43 | attachment_lines: 50 44 | notification_cooldown: 2 45 | action_cooldown: 300 46 | 47 | keywords: 48 | - keyword1 49 | keywords_with_attachment: 50 | - keyword2 51 | action_keywords: 52 | - stop: keyword3 53 | - restart: 54 | regex: regex-pattern # this is how to set regex patterns for action_keywords 55 | 56 | # Global keywords are applied to all containers 57 | global_keywords: 58 | keywords: 59 | - global_keyword1 60 | keywords_with_attachment: 61 | - global_keyword2 62 | 63 | notifications: 64 | # At least one of these (Ntfy/Apprise/Webhook) is required. 65 | ntfy: 66 | url: http://your-ntfy-server # Required. The URL of your Ntfy instance 67 | topic: loggifly # Required. the topic for Ntfy 68 | token: ntfy-token # Ntfy token in case you need authentication 69 | username: john # Ntfy Username+Password in case you need authentication 70 | password: password # Ntfy Username+Password in case you need authentication 71 | priority: 3 # Ntfy priority (1-5) 72 | tags: kite,mag # Ntfy tags/emojis 73 | apprise: 74 | url: "discord://webhook-url" # Any Apprise-compatible URL (https://github.com/caronc/apprise/wiki) 75 | webhook: 76 | url: https://custom.endpoint.com/post 77 | headers: # add headers if needed 78 | Authorization: "Bearer token" 79 | X-Custom-Header": "Test123" 80 | 81 | # These are the default settings 82 | settings: 83 | log_level: INFO # DEBUG, INFO, WARNING, ERROR 84 | notification_cooldown: 5 # Seconds between alerts for same keyword (per container) 85 | action_cooldown: 300 # Cooldown period (in seconds) before the next container action can be performed. Maximum is always at least 60s. 86 | notification_title: default # configure a custom template for the notification title (see section below) attachment_lines: 20 # Number of Lines to include in log attachments 87 | multi_line_entries: True # Monitor and catch multi-line log entries instead of going line by line. 88 | reload_config: True # When the config file is changed the program reloads the config 89 | disable_start_message: False # Suppress startup notification 90 | disable_shutdown_message: False # Suppress shutdown notification 91 | disable_config_reload_message: False # Suppress config reload notification 92 | disable_container_event_message: False # Suppress notification when monitoring of containers start/stop 93 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | loggifly: 4 | image: ghcr.io/clemcer/loggifly:latest 5 | container_name: loggifly 6 | volumes: 7 | - /var/run/docker.sock:/var/run/docker.sock 8 | # Comment out the following if you are only using environment variables. 9 | # A config template will be downloaded in /config 10 | # edit and rename it to config.yaml or create a new file with your config in it 11 | - ./loggifly/config.yaml:/config 12 | environment: 13 | - TZ=Europe/Berlin 14 | restart: unless-stopped 15 | 16 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CONFIG_DIR=/config 4 | CONFIG_TEMPLATE=$CONFIG_DIR/config_template.yaml 5 | CONFIG_FILE=$CONFIG_DIR/config.yaml 6 | CONFIG_URL=https://raw.githubusercontent.com/clemcer/loggifly/refs/heads/main/config_template.yaml 7 | 8 | if [ -d "$CONFIG_DIR" ]; then 9 | #echo "/config exists." 10 | if [ ! -f "$CONFIG_TEMPLATE" ] && [ ! -f "$CONFIG_FILE" ]; then 11 | echo "loading config.yaml template..." 12 | python3 -c "import urllib.request; urllib.request.urlretrieve('$CONFIG_URL', '$CONFIG_TEMPLATE')" 13 | # else 14 | # echo "config.yaml or config template already exists." 15 | fi 16 | else 17 | echo "/config does not exist, skipping config template download." 18 | fi 19 | 20 | exec python /app/app.py -------------------------------------------------------------------------------- /images/abs_download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/abs_download.png -------------------------------------------------------------------------------- /images/abs_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/abs_login.png -------------------------------------------------------------------------------- /images/abs_template_examples.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/abs_template_examples.png -------------------------------------------------------------------------------- /images/abs_with_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/abs_with_template.png -------------------------------------------------------------------------------- /images/abs_without_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/abs_without_template.png -------------------------------------------------------------------------------- /images/audiobookshelf_download_custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/audiobookshelf_download_custom.png -------------------------------------------------------------------------------- /images/authelia_custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/authelia_custom.png -------------------------------------------------------------------------------- /images/collage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/collage.png -------------------------------------------------------------------------------- /images/ebook2audiobook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/ebook2audiobook.png -------------------------------------------------------------------------------- /images/gluetun_summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/gluetun_summary.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/icon.png -------------------------------------------------------------------------------- /images/template_collage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/template_collage.png -------------------------------------------------------------------------------- /images/template_collage_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/template_collage_rounded.png -------------------------------------------------------------------------------- /images/vault_failed_login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/vault_failed_login.gif -------------------------------------------------------------------------------- /images/vault_failed_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/vault_failed_login.png -------------------------------------------------------------------------------- /images/vaultwarden_custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clemcer/loggifly/065c26797db6cad4f3c467238b073decff9724bc/images/vaultwarden_custom.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | docker 2 | requests 3 | pyyaml 4 | watchdog 5 | apprise 6 | pydantic --------------------------------------------------------------------------------