├── .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 |
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 |

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 |

76 |
77 |
78 |
79 |
80 | ### 🎯 Customize notifications and filter log lines for relevant information:
81 |
82 |
83 |

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 |
766 |
767 |
768 |
769 | # Star History
770 |
771 | [](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
--------------------------------------------------------------------------------