From 04de1ab8a2928cdd781c61589dd9dee0c5ca6722 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 1 Aug 2026 12:44:27 -0700 Subject: [PATCH 1/4] feat: add plugin logging APIs --- docs/PLUGIN_AUTHOR_GUIDE.md | 26 ++++++++++++++++--- docs/WIRE_PROTOCOL.md | 5 ++++ engines/build_py.py | 3 +++ engines/javascript/engine.d.ts | 3 +++ examples/js/announcer/INSTRUCTIONS.md | 2 +- examples/js/announcer/README.md | 2 +- .../announcer/__tests__/announcer.test.json | 9 +++++-- examples/js/announcer/src/plugin.js | 4 +-- examples/js/chat-logger/INSTRUCTIONS.md | 11 ++++---- examples/js/chat-logger/README.md | 4 +-- .../js/chat-logger/__tests__/log.test.json | 19 ++++++++++++-- examples/js/chat-logger/plugin.manifest.json | 2 +- examples/js/chat-logger/src/plugin.js | 11 ++++++-- examples/python/announcer/INSTRUCTIONS.md | 2 +- examples/python/announcer/README.md | 2 +- .../announcer/__tests__/announcer.test.json | 9 +++++-- examples/python/announcer/src/plugin.py | 4 +-- examples/python/chat-logger/INSTRUCTIONS.md | 11 ++++---- examples/python/chat-logger/README.md | 4 +-- .../chat-logger/__tests__/log.test.json | 19 ++++++++++++-- .../python/chat-logger/plugin.manifest.json | 2 +- examples/python/chat-logger/src/plugin.py | 11 +++++--- host-runtime/cmd/owncast-plugin-serve/main.go | 10 +++++++ host-runtime/main.go | 10 +++++++ host-runtime/plugin/testing/mocks.go | 19 ++++++++++++++ host-runtime/plugin/testing/runner.go | 24 +++++++++++++++++ host-runtime/plugin/testing/scenario.go | 7 +++++ sdks/js/index.d.ts | 11 ++++++-- sdks/js/index.js | 17 ++++++++++++ sdks/python/owncast_plugin/__init__.py | 12 +++++++++ 30 files changed, 233 insertions(+), 42 deletions(-) diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index a29166f..05fc06d 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -380,8 +380,27 @@ Each method requires the matching permission in your manifest: | `owncast.sse.send(channel, event, data)`, push to browsers | `http.sse` | | `owncast.timer.setTimeout/setInterval/clear(...)`, schedule callbacks | none (ambient) | | `owncast.config.get(key, fallback?)`, read manifest-declared config | none (ambient) | +| `owncast.log.info/warning/error(message)`, write to the Owncast server log | none (ambient) | -Calling an API without its permission throws a clear error. +Calling a permission-gated API without its permission throws a clear error. + +### Server logging + +Use `owncast.log` for operator-visible plugin logs: + +```js +owncast.log.info("sync started"); +owncast.log.warning("provider response is incomplete"); +owncast.log.error("sync failed"); +``` + +```python +owncast.log.info("sync started") +owncast.log.warning("provider response is incomplete") +owncast.log.error("sync failed") +``` + +`info`, `warning`, and `error` map directly to the same Owncast logrus levels. Every entry includes the calling plugin's slug, for example `plugin schedule-sync: sync started`. No permission is required. Prefer this API over `console.log` or `print` when the level and plugin identity need to reach the Owncast log reliably. ### SQL database @@ -1148,6 +1167,7 @@ Per-step `expect` (on filter and http steps): Final-state `expect` (on the whole scenario): - `chatSends`, `chatActions`, `chatSystems`, exact-match lists of chat posts (the bot-sent, "/me" action, and system message variants). Captures sends from any step, including chat posted from an `onHttpRequest` handler. +- `logs`, ordered list of `{plugin, level, message}` entries written through `owncast.log` - `chatTo`, list of `{clientId, text}` private replies (`owncast.chat.sendTo` / `replyTo`) - `sseSends`, ordered list of `{channel, event?, data?}` pushed via `owncast.sse.send` (omit `event`/`data` to match only on channel) - `videoConfigWrites`, list of partial configs applied via `owncast.videoConfig.write()` @@ -1441,7 +1461,7 @@ module.exports = definePlugin({ module.exports = definePlugin({ on: { "announcement.broadcast"(payload) { - console.log(`📢 ${payload.by}: ${payload.text}`); + owncast.log.info(`Announcement from ${payload.by}: ${payload.text}`); }, }, }); @@ -1451,7 +1471,7 @@ module.exports = definePlugin({ - **TypeScript works**, name your file `src/plugin.ts` instead of `.js`. The SDK ships TypeScript declarations. Use `import` instead of `require`. - **Third-party code is limited.** npm packages must be pure JavaScript (no Node built-ins like `fs` or `http`). Python has no `pip`: to use a library, copy its pure-Python source into your project. For outbound HTTP call `owncast.http.fetch`, not `requests` or Node's `http`. -- **`console.log`** in plugin code surfaces in the host log with a `[your-plugin]` prefix. Use it freely for debugging. +- **Server logs:** use `owncast.log.info`, `.warning`, or `.error`. Owncast preserves the level and adds your plugin slug. - **One handler = one subscription.** Define `onChatMessage` → subscribed. Delete it → unsubscribed. Don't think about it. - **Mocked tests are fast** (3-5 s including rebuild). Run them on every save. - **State doesn't leak between scenarios.** Each test gets a fresh plugin instance and a clean in-memory plugin config. diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index fe28e8c..d85197a 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -293,6 +293,11 @@ These imports are granted to every plugin without a declared permission. A plugi - `owncast_timer_clear(id: I64): void`, cancel a pending timer by id. - `owncast_config_get(keyPtr: PTR): PTR`, returns the JSON value of a `manifest.config` key (admin override, else declared default), or 0-offset for an unknown/unset key. - `owncast_asset_read(pathPtr: PTR): PTR`, returns the raw bytes of a file from the plugin's own `assets/` directory, or 0-offset when the file is missing or the path escapes the directory. The path is relative to `assets/` and must not start with `/` or contain `..` segments. The host rejects any path that would escape the plugin's own asset tree. Plugins use this to load bundled resources (templates, data files) at request time without needing `storage.fs`. +- `owncast_log_info(messagePtr: PTR): void`, write an info entry to the Owncast server log +- `owncast_log_warning(messagePtr: PTR): void`, write a warning entry to the Owncast server log +- `owncast_log_error(messagePtr: PTR): void`, write an error entry to the Owncast server log + +The host attributes every entry to the calling plugin's slug and preserves the selected severity in its logrus output. The fixed functions keep unknown levels out of the wire contract. The host also dispatches a `tick` event (payload `{now}`, host wall-clock ms) about once a second to any plugin defining `onTick`, independent of timers. diff --git a/engines/build_py.py b/engines/build_py.py index 7dffa12..dc815a0 100644 --- a/engines/build_py.py +++ b/engines/build_py.py @@ -118,6 +118,9 @@ ("owncast_timer_clear", "timer_id: int"), ("owncast_config_get", "key: str", "str"), ("owncast_asset_read", "path: str", "str"), + ("owncast_log_info", "message: str"), + ("owncast_log_warning", "message: str"), + ("owncast_log_error", "message: str"), ] diff --git a/engines/javascript/engine.d.ts b/engines/javascript/engine.d.ts index 0a374f2..fe7f981 100644 --- a/engines/javascript/engine.d.ts +++ b/engines/javascript/engine.d.ts @@ -21,6 +21,9 @@ declare module 'extism:host' { owncast_timer_clear(id: I64): void; owncast_config_get(keyPtr: PTR): PTR; owncast_asset_read(pathPtr: PTR): PTR; + owncast_log_info(messagePtr: PTR): void; + owncast_log_warning(messagePtr: PTR): void; + owncast_log_error(messagePtr: PTR): void; owncast_send_chat(textPtr: PTR): void; owncast_send_chat_action(textPtr: PTR): void; owncast_send_chat_system(bodyPtr: PTR): void; diff --git a/examples/js/announcer/INSTRUCTIONS.md b/examples/js/announcer/INSTRUCTIONS.md index 18bedc8..6532bdd 100644 --- a/examples/js/announcer/INSTRUCTIONS.md +++ b/examples/js/announcer/INSTRUCTIONS.md @@ -8,7 +8,7 @@ This plugin does nothing on its own. It's one half of a pair. 1. Install and enable **both** this plugin and the **relay** plugin. 2. In chat, type `/announce ` (that command is handled by relay). -3. relay emits an `announcement.broadcast` event. This plugin receives it and writes a line to the **server log** (stderr). +3. relay emits an `announcement.broadcast` event. This plugin receives it and writes an info entry to the Owncast server log through `owncast.log.info`. There is no viewer-facing output. Watch the Owncast server logs to see it fire. diff --git a/examples/js/announcer/README.md b/examples/js/announcer/README.md index 15629ea..9d68897 100644 --- a/examples/js/announcer/README.md +++ b/examples/js/announcer/README.md @@ -2,4 +2,4 @@ Subscribes to the custom `announcement.broadcast` event emitted by `../relay` and logs it. The event type is a plugin-defined string, not a built-in Owncast event. -**Demonstrates:** custom-event subscription via the `on: { ... }` object in `definePlugin`. No `events.emit` permission needed, that's only required to _emit_, not to receive. +**Demonstrates:** custom-event subscription via the `on: { ... }` object in `definePlugin` and info-level server logging through `owncast.log.info`. Neither receiving the event nor writing the log requires a permission. diff --git a/examples/js/announcer/__tests__/announcer.test.json b/examples/js/announcer/__tests__/announcer.test.json index 1a77f0d..5a95952 100644 --- a/examples/js/announcer/__tests__/announcer.test.json +++ b/examples/js/announcer/__tests__/announcer.test.json @@ -1,11 +1,16 @@ [ { - "name": "handles announcement.broadcast without error", + "name": "logs announcement.broadcast at info level", "events": [ { "event": "announcement.broadcast", "payload": { "by": "alice", "text": "stream is live", "at": "2024-01-01T00:00:00Z" } } - ] + ], + "expect": { + "logs": [ + { "plugin": "announcer", "level": "info", "message": "Announcement from alice: stream is live" } + ] + } } ] diff --git a/examples/js/announcer/src/plugin.js b/examples/js/announcer/src/plugin.js index 378174b..f890272 100644 --- a/examples/js/announcer/src/plugin.js +++ b/examples/js/announcer/src/plugin.js @@ -1,9 +1,9 @@ -const { definePlugin } = require("@owncast/plugin-sdk"); +const { definePlugin, owncast } = require("@owncast/plugin-sdk"); module.exports = definePlugin({ on: { "announcement.broadcast"(payload) { - console.log(`ANNOUNCEMENT from ${payload.by}: ${payload.text}`); + owncast.log.info(`Announcement from ${payload.by}: ${payload.text}`); } } }); diff --git a/examples/js/chat-logger/INSTRUCTIONS.md b/examples/js/chat-logger/INSTRUCTIONS.md index 34f8b6f..41214c5 100644 --- a/examples/js/chat-logger/INSTRUCTIONS.md +++ b/examples/js/chat-logger/INSTRUCTIONS.md @@ -1,15 +1,16 @@ # Chat Logger -Writes a line to the Owncast **server log** for every chat message. A read-only example that needs no permissions. +Writes a line to the Owncast server log for every chat message. A read-only example that needs no permissions. ## How to use it 1. Enable the plugin in **Admin → Plugins**. -2. Have someone post in chat. -3. Each message shows up in the server's standard-error output, prefixed with `[chat-logger]`. +2. Post a normal chat message to write an info entry. +3. Post a message starting with `warning:` to write a warning entry. +4. Post a message starting with `error:` to write an error entry. -There is nothing to configure and no viewer-facing output. This is purely a server-side log. It's a good starting point for an analytics or archival plugin. +Each line includes `plugin chat-logger:` so an operator can identify its source. There is nothing to configure and no viewer-facing output. ## Permissions -None. A plugin can observe chat events through the message handler without declaring any permission. Permissions are only required to *act* (send, moderate, and so on). +None. Plugins can observe chat events and write attributed server log entries without declaring a permission. diff --git a/examples/js/chat-logger/README.md b/examples/js/chat-logger/README.md index c3f8c8b..e2c9b5d 100644 --- a/examples/js/chat-logger/README.md +++ b/examples/js/chat-logger/README.md @@ -1,5 +1,5 @@ # chat-logger -Logs every chat message to stderr with a `[chat-logger]` prefix. No permissions required, read-only via the event payload. +Logs every chat message through Owncast's server log. Messages that start with `warning:` or `error:` use the matching level. Other messages use info. No permission is required. -**Demonstrates:** the `onChatMessage` notification handler, `console.log` debugging, the zero-permissions case (a plugin can react to events without declaring anything). +**Demonstrates:** the `onChatMessage` notification handler, `owncast.log.info/warning/error`, and a public host capability available with an empty permissions list. diff --git a/examples/js/chat-logger/__tests__/log.test.json b/examples/js/chat-logger/__tests__/log.test.json index 8f3a29b..6f5e7a4 100644 --- a/examples/js/chat-logger/__tests__/log.test.json +++ b/examples/js/chat-logger/__tests__/log.test.json @@ -1,11 +1,26 @@ [ { - "name": "handles chat.message.received without error", + "name": "preserves info, warning, and error levels", "events": [ { "event": "chat.message.received", "payload": { "id": "1", "user": { "id": "u-alice", "displayName": "alice" }, "body": "hello", "timestamp": "2024-01-01T00:00:00Z" } + }, + { + "event": "chat.message.received", + "payload": { "id": "2", "user": { "id": "u-alice", "displayName": "alice" }, "body": "warning: check this", "timestamp": "2024-01-01T00:00:01Z" } + }, + { + "event": "chat.message.received", + "payload": { "id": "3", "user": { "id": "u-alice", "displayName": "alice" }, "body": "error: failed", "timestamp": "2024-01-01T00:00:02Z" } } - ] + ], + "expect": { + "logs": [ + { "plugin": "chat-logger", "level": "info", "message": "alice: hello" }, + { "plugin": "chat-logger", "level": "warning", "message": "alice: warning: check this" }, + { "plugin": "chat-logger", "level": "error", "message": "alice: error: failed" } + ] + } } ] diff --git a/examples/js/chat-logger/plugin.manifest.json b/examples/js/chat-logger/plugin.manifest.json index b37f313..bebf7ff 100644 --- a/examples/js/chat-logger/plugin.manifest.json +++ b/examples/js/chat-logger/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Chat Logger", "slug": "chat-logger", "version": "0.3.2", - "description": "Logs every chat message via console.log (host stderr). This example was written in JavaScript.", + "description": "Logs chat messages at info, warning, or error level through Owncast's server log. This example was written in JavaScript.", "category": "examples", "permissions": [] } diff --git a/examples/js/chat-logger/src/plugin.js b/examples/js/chat-logger/src/plugin.js index 54c099b..022ba5b 100644 --- a/examples/js/chat-logger/src/plugin.js +++ b/examples/js/chat-logger/src/plugin.js @@ -1,7 +1,14 @@ -const { definePlugin } = require("@owncast/plugin-sdk"); +const { definePlugin, owncast } = require("@owncast/plugin-sdk"); module.exports = definePlugin({ onChatMessage(msg) { - console.log(`${msg.user ? msg.user.displayName : "?"}: ${msg.body}`); + const line = `${msg.user ? msg.user.displayName : "?"}: ${msg.body}`; + if (msg.body.startsWith("error:")) { + owncast.log.error(line); + } else if (msg.body.startsWith("warning:")) { + owncast.log.warning(line); + } else { + owncast.log.info(line); + } } }); diff --git a/examples/python/announcer/INSTRUCTIONS.md b/examples/python/announcer/INSTRUCTIONS.md index 18bedc8..6532bdd 100644 --- a/examples/python/announcer/INSTRUCTIONS.md +++ b/examples/python/announcer/INSTRUCTIONS.md @@ -8,7 +8,7 @@ This plugin does nothing on its own. It's one half of a pair. 1. Install and enable **both** this plugin and the **relay** plugin. 2. In chat, type `/announce ` (that command is handled by relay). -3. relay emits an `announcement.broadcast` event. This plugin receives it and writes a line to the **server log** (stderr). +3. relay emits an `announcement.broadcast` event. This plugin receives it and writes an info entry to the Owncast server log through `owncast.log.info`. There is no viewer-facing output. Watch the Owncast server logs to see it fire. diff --git a/examples/python/announcer/README.md b/examples/python/announcer/README.md index a01c2fe..a4d0e4d 100644 --- a/examples/python/announcer/README.md +++ b/examples/python/announcer/README.md @@ -2,4 +2,4 @@ Subscribes to the custom `announcement.broadcast` event emitted by `../relay` and logs it. The event type is a plugin-defined string, not a built-in Owncast event. -**Demonstrates:** custom-event subscription via the `@plugin.on("announcement.broadcast")` decorator. No `events.emit` permission needed. That's only required to _emit_, not to receive. +**Demonstrates:** custom-event subscription via the `@plugin.on("announcement.broadcast")` decorator and info-level server logging through `owncast.log.info`. Neither receiving the event nor writing the log requires a permission. diff --git a/examples/python/announcer/__tests__/announcer.test.json b/examples/python/announcer/__tests__/announcer.test.json index 1a77f0d..5a95952 100644 --- a/examples/python/announcer/__tests__/announcer.test.json +++ b/examples/python/announcer/__tests__/announcer.test.json @@ -1,11 +1,16 @@ [ { - "name": "handles announcement.broadcast without error", + "name": "logs announcement.broadcast at info level", "events": [ { "event": "announcement.broadcast", "payload": { "by": "alice", "text": "stream is live", "at": "2024-01-01T00:00:00Z" } } - ] + ], + "expect": { + "logs": [ + { "plugin": "announcer", "level": "info", "message": "Announcement from alice: stream is live" } + ] + } } ] diff --git a/examples/python/announcer/src/plugin.py b/examples/python/announcer/src/plugin.py index f12ad80..f522681 100644 --- a/examples/python/announcer/src/plugin.py +++ b/examples/python/announcer/src/plugin.py @@ -1,8 +1,8 @@ -from owncast_plugin import plugin +from owncast_plugin import owncast, plugin @plugin.on("announcement.broadcast") def handle(payload): by = payload.get("by") if isinstance(payload, dict) else None text = payload.get("text") if isinstance(payload, dict) else None - print(f"ANNOUNCEMENT from {by}: {text}") + owncast.log.info(f"Announcement from {by}: {text}") diff --git a/examples/python/chat-logger/INSTRUCTIONS.md b/examples/python/chat-logger/INSTRUCTIONS.md index 34f8b6f..41214c5 100644 --- a/examples/python/chat-logger/INSTRUCTIONS.md +++ b/examples/python/chat-logger/INSTRUCTIONS.md @@ -1,15 +1,16 @@ # Chat Logger -Writes a line to the Owncast **server log** for every chat message. A read-only example that needs no permissions. +Writes a line to the Owncast server log for every chat message. A read-only example that needs no permissions. ## How to use it 1. Enable the plugin in **Admin → Plugins**. -2. Have someone post in chat. -3. Each message shows up in the server's standard-error output, prefixed with `[chat-logger]`. +2. Post a normal chat message to write an info entry. +3. Post a message starting with `warning:` to write a warning entry. +4. Post a message starting with `error:` to write an error entry. -There is nothing to configure and no viewer-facing output. This is purely a server-side log. It's a good starting point for an analytics or archival plugin. +Each line includes `plugin chat-logger:` so an operator can identify its source. There is nothing to configure and no viewer-facing output. ## Permissions -None. A plugin can observe chat events through the message handler without declaring any permission. Permissions are only required to *act* (send, moderate, and so on). +None. Plugins can observe chat events and write attributed server log entries without declaring a permission. diff --git a/examples/python/chat-logger/README.md b/examples/python/chat-logger/README.md index caee3ad..56cb2dd 100644 --- a/examples/python/chat-logger/README.md +++ b/examples/python/chat-logger/README.md @@ -1,5 +1,5 @@ # chat-logger -Logs every chat message to stderr with the poster's name. No permissions required: read-only via the event payload. +Logs every chat message through Owncast's server log. Messages that start with `warning:` or `error:` use the matching level. Other messages use info. No permission is required. -**Demonstrates:** the `@plugin.on_chat_message` notification handler, `print()` debugging (host stderr), the zero-permissions case (a plugin can react to events without declaring anything). +**Demonstrates:** the `@plugin.on_chat_message` notification handler, `owncast.log.info/warning/error`, and a public host capability available with an empty permissions list. diff --git a/examples/python/chat-logger/__tests__/log.test.json b/examples/python/chat-logger/__tests__/log.test.json index 8f3a29b..6f5e7a4 100644 --- a/examples/python/chat-logger/__tests__/log.test.json +++ b/examples/python/chat-logger/__tests__/log.test.json @@ -1,11 +1,26 @@ [ { - "name": "handles chat.message.received without error", + "name": "preserves info, warning, and error levels", "events": [ { "event": "chat.message.received", "payload": { "id": "1", "user": { "id": "u-alice", "displayName": "alice" }, "body": "hello", "timestamp": "2024-01-01T00:00:00Z" } + }, + { + "event": "chat.message.received", + "payload": { "id": "2", "user": { "id": "u-alice", "displayName": "alice" }, "body": "warning: check this", "timestamp": "2024-01-01T00:00:01Z" } + }, + { + "event": "chat.message.received", + "payload": { "id": "3", "user": { "id": "u-alice", "displayName": "alice" }, "body": "error: failed", "timestamp": "2024-01-01T00:00:02Z" } } - ] + ], + "expect": { + "logs": [ + { "plugin": "chat-logger", "level": "info", "message": "alice: hello" }, + { "plugin": "chat-logger", "level": "warning", "message": "alice: warning: check this" }, + { "plugin": "chat-logger", "level": "error", "message": "alice: error: failed" } + ] + } } ] diff --git a/examples/python/chat-logger/plugin.manifest.json b/examples/python/chat-logger/plugin.manifest.json index 363dc14..c68916c 100644 --- a/examples/python/chat-logger/plugin.manifest.json +++ b/examples/python/chat-logger/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Chat Logger", "slug": "chat-logger", "version": "0.3.2", - "description": "Logs every chat message via console.log (host stderr). This example was written in Python.", + "description": "Logs chat messages at info, warning, or error level through Owncast's server log. This example was written in Python.", "category": "examples", "permissions": [] } diff --git a/examples/python/chat-logger/src/plugin.py b/examples/python/chat-logger/src/plugin.py index fa3b12c..032000b 100644 --- a/examples/python/chat-logger/src/plugin.py +++ b/examples/python/chat-logger/src/plugin.py @@ -1,7 +1,12 @@ -from owncast_plugin import plugin +from owncast_plugin import owncast, plugin @plugin.on_chat_message def log(msg): - name = msg.user.display_name if msg.user else "?" - print(f"{name}: {msg.body}") + line = f"{msg.user.display_name if msg.user else '?'}: {msg.body}" + if msg.body.startswith("error:"): + owncast.log.error(line) + elif msg.body.startswith("warning:"): + owncast.log.warning(line) + else: + owncast.log.info(line) diff --git a/host-runtime/cmd/owncast-plugin-serve/main.go b/host-runtime/cmd/owncast-plugin-serve/main.go index 3b2f748..9629ec6 100644 --- a/host-runtime/cmd/owncast-plugin-serve/main.go +++ b/host-runtime/cmd/owncast-plugin-serve/main.go @@ -82,6 +82,16 @@ func main() { env := &plugin.HostEnv{ KV: store, OnChat: dev.onPluginChat, + Log: func(pluginName string, level plugin.PluginLogLevel, message string) { + switch level { + case plugin.PluginLogWarning: + fmt.Fprintf(os.Stderr, "[warning from plugin %s] %s\n", pluginName, message) + case plugin.PluginLogError: + fmt.Fprintf(os.Stderr, "[error from plugin %s] %s\n", pluginName, message) + default: + fmt.Fprintf(os.Stderr, "[info from plugin %s] %s\n", pluginName, message) + } + }, // Read-only server/stream state. Sample values so a plugin that // gates on them (server.read) has something plausible to work with. diff --git a/host-runtime/main.go b/host-runtime/main.go index 0c55215..8555885 100644 --- a/host-runtime/main.go +++ b/host-runtime/main.go @@ -66,6 +66,16 @@ func main() { env := &plugin.HostEnv{ KV: store, + Log: func(pluginName string, level plugin.PluginLogLevel, message string) { + switch level { + case plugin.PluginLogWarning: + fmt.Fprintf(os.Stderr, "[warning from plugin %s] %s\n", pluginName, message) + case plugin.PluginLogError: + fmt.Fprintf(os.Stderr, "[error from plugin %s] %s\n", pluginName, message) + default: + fmt.Fprintf(os.Stderr, "[info from plugin %s] %s\n", pluginName, message) + } + }, OnChat: func(req plugin.ChatSendRequest) { // Identify the plugin in dev-host logs by display name when set, // otherwise the slug. Bot.DisplayName drives what would show in diff --git a/host-runtime/plugin/testing/mocks.go b/host-runtime/plugin/testing/mocks.go index 0611fd9..77aa43a 100644 --- a/host-runtime/plugin/testing/mocks.go +++ b/host-runtime/plugin/testing/mocks.go @@ -22,6 +22,13 @@ type EmittedEvent struct { Payload any } +// RecordedLog captures one owncast.log call. +type RecordedLog struct { + Plugin string + Level plugin.PluginLogLevel + Message string +} + // MockHost is a HostEnv implementation that records side effects in memory. // Each scenario gets its own MockHost so state is isolated. // @@ -106,6 +113,7 @@ type MockHost struct { chatSends []string chatActions []string chatSystems []string + logs []RecordedLog emits []EmittedEvent httpFixtures []HTTPFixture httpRecords []RecordedHTTPRequest @@ -208,6 +216,11 @@ func (m *MockHost) HostEnv() *plugin.HostEnv { m.chatSends = append(m.chatSends, req.Text) } }, + Log: func(pluginName string, level plugin.PluginLogLevel, message string) { + m.mu.Lock() + defer m.mu.Unlock() + m.logs = append(m.logs, RecordedLog{Plugin: pluginName, Level: level, Message: message}) + }, Emit: func(_ context.Context, eventType string, payload any) { m.mu.Lock() defer m.mu.Unlock() @@ -468,6 +481,12 @@ func (m *MockHost) SetUsers(u []plugin.HostUser) { m.users = append([]plugin.HostUser(nil), u...) } +func (m *MockHost) Logs() []RecordedLog { + m.mu.Lock() + defer m.mu.Unlock() + return append([]RecordedLog(nil), m.logs...) +} + func (m *MockHost) UserModerations() []RecordedUserModeration { m.mu.Lock() defer m.mu.Unlock() diff --git a/host-runtime/plugin/testing/runner.go b/host-runtime/plugin/testing/runner.go index f38b31a..d3b7cd1 100644 --- a/host-runtime/plugin/testing/runner.go +++ b/host-runtime/plugin/testing/runner.go @@ -348,6 +348,17 @@ func runHTTPStep(server *plugin.Server, pluginName string, h *HTTPStep) error { return nil } +func pluginLogLevelName(level plugin.PluginLogLevel) string { + switch level { + case plugin.PluginLogWarning: + return "warning" + case plugin.PluginLogError: + return "error" + default: + return "info" + } +} + func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginName string, commands []plugin.CommandInfo) { if e.ChatSends != nil { got := mock.ChatSends() @@ -370,6 +381,19 @@ func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginNam res.Errors = append(res.Errors, fmt.Sprintf("chatSystems mismatch:\n want %v\n got %v", e.ChatSystems, got)) } } + if e.Logs != nil { + got := mock.Logs() + if len(e.Logs) != len(got) { + res.Errors = append(res.Errors, fmt.Sprintf("logs count: want %d got %d", len(e.Logs), len(got))) + } else { + for i, want := range e.Logs { + level := pluginLogLevelName(got[i].Level) + if want.Plugin != got[i].Plugin || want.Level != level || want.Message != got[i].Message { + res.Errors = append(res.Errors, fmt.Sprintf("logs[%d]: want %+v got {Plugin:%s Level:%s Message:%s}", i, want, got[i].Plugin, level, got[i].Message)) + } + } + } + } if e.DeletedMessages != nil { got := mock.DeletedMessages() if (len(e.DeletedMessages) != 0 || len(got) != 0) && !reflect.DeepEqual(e.DeletedMessages, got) { diff --git a/host-runtime/plugin/testing/scenario.go b/host-runtime/plugin/testing/scenario.go index 229bf25..9289834 100644 --- a/host-runtime/plugin/testing/scenario.go +++ b/host-runtime/plugin/testing/scenario.go @@ -189,6 +189,7 @@ type ScenarioExpect struct { ChatSends []string `json:"chatSends,omitempty"` ChatActions []string `json:"chatActions,omitempty"` ChatSystems []string `json:"chatSystems,omitempty"` + Logs []ScenarioLogExpect `json:"logs,omitempty"` DeletedMessages []string `json:"deletedMessages,omitempty"` KickedClients []uint64 `json:"kickedClients,omitempty"` DiscordPosts []string `json:"discordPosts,omitempty"` @@ -210,6 +211,12 @@ type ScenarioExpect struct { Commands []ScenarioCommandExpect `json:"commands,omitempty"` } +type ScenarioLogExpect struct { + Plugin string `json:"plugin"` + Level string `json:"level"` + Message string `json:"message"` +} + // ScenarioCommandExpect asserts on one core-routed command registration. // Entries are matched by Name in any order. Prefix, Description, Usage, and // Aliases are checked only when set. ModOnly, CaseSensitive, and CooldownMs are diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index 982f714..6fc1d2c 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -554,9 +554,16 @@ export interface CommandEvent { argString: string; } -/** Typed wrappers around the Owncast host. Each method throws if the - * corresponding permission was not declared in plugin.manifest.json. */ +/** Typed wrappers around the Owncast host. Methods that require a permission + * say so in their documentation and throw when it was not declared. */ export const owncast: { + /** Write a plugin-attributed entry to Owncast's server log. No permission + * is required. */ + log: { + info(message: string): void; + warning(message: string): void; + error(message: string): void; + }; chat: { /** Post as the plugin's own chat bot (display name = the plugin's name). */ send(text: string): void; diff --git a/sdks/js/index.js b/sdks/js/index.js index f9f89b6..c5ad8f7 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -440,7 +440,24 @@ function sqlQuery(sql, params, maxRows) { return sqlResult(fns.owncast_sql_query(request.offset)); } +function logToHost(name, message) { + const fn = Host.getFunctions()[name]; + if (!fn) throw new Error("owncast.log is unavailable in this host"); + fn(Memory.fromString(String(message)).offset); +} + const owncast = { + log: { + info(message) { + logToHost("owncast_log_info", message); + }, + warning(message) { + logToHost("owncast_log_warning", message); + }, + error(message) { + logToHost("owncast_log_error", message); + }, + }, chat: { send(text) { const fns = hostFns("owncast_send_chat", Permissions.ChatSend); diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index f82eb45..2e34634 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -383,6 +383,17 @@ def _dispatch_command(event): # --------------------------------------------------------------------------- # owncast.* host facade. # --------------------------------------------------------------------------- +class _Log: + def info(self, message): + _host("owncast_log_info")(str(message)) + + def warning(self, message): + _host("owncast_log_warning")(str(message)) + + def error(self, message): + _host("owncast_log_error")(str(message)) + + class _Chat: def send(self, text): _host("owncast_send_chat")(str(text)) @@ -732,6 +743,7 @@ def fetch(self, url, opts=None): class _Owncast: http = _Http() + log = _Log() chat = _Chat() kv = _KV() storage = _Storage() From 80bd304d01a2579dee53a21196a5b3cc8d9fad55 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 1 Aug 2026 18:16:29 -0700 Subject: [PATCH 2/4] fix: clarify plugin logging availability --- sdks/js/index.js | 2 -- sdks/python/owncast_plugin/__init__.py | 12 +++++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/sdks/js/index.js b/sdks/js/index.js index c5ad8f7..e23299e 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -430,8 +430,6 @@ function sqlRows(result) { }); } -// sqlQuery issues one query request. maxRows is deliberately not an author -// parameter: it exists so queryRow can ask the host for a single row. function sqlQuery(sql, params, maxRows) { const fns = hostFns("owncast_sql_query", Permissions.StorageSQL); const payload = { sql: String(sql), params: Array.from(params || []) }; diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index 2e34634..15a0775 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -384,14 +384,20 @@ def _dispatch_command(event): # owncast.* host facade. # --------------------------------------------------------------------------- class _Log: + def _write(self, name, message): + fn = _HOST.get(name) + if fn is None: + raise RuntimeError("owncast.log is unavailable in this host") + fn(str(message)) + def info(self, message): - _host("owncast_log_info")(str(message)) + self._write("owncast_log_info", message) def warning(self, message): - _host("owncast_log_warning")(str(message)) + self._write("owncast_log_warning", message) def error(self, message): - _host("owncast_log_error")(str(message)) + self._write("owncast_log_error", message) class _Chat: From 519e8df91e5048362794c8a1ec4627fe95193ef8 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 1 Aug 2026 20:12:12 -0700 Subject: [PATCH 3/4] docs: describe plugin log safeguards --- docs/PLUGIN_AUTHOR_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 05fc06d..6744dae 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -400,7 +400,7 @@ owncast.log.warning("provider response is incomplete") owncast.log.error("sync failed") ``` -`info`, `warning`, and `error` map directly to the same Owncast logrus levels. Every entry includes the calling plugin's slug, for example `plugin schedule-sync: sync started`. No permission is required. Prefer this API over `console.log` or `print` when the level and plugin identity need to reach the Owncast log reliably. +`info`, `warning`, and `error` map directly to the same Owncast logrus levels. Every entry includes the calling plugin's slug, for example `plugin schedule-sync: sync started`. Owncast replaces control characters with spaces so each entry stays on one line, then truncates messages longer than 4 KiB. No permission is required. Prefer this API over `console.log` or `print` when the level and plugin identity need to reach the Owncast log reliably. ### SQL database From 7a138280b1815b55e31542742da3360c74443cb8 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 1 Aug 2026 20:20:10 -0700 Subject: [PATCH 4/4] ci: test paired Owncast branches --- .github/workflows/examples-js.yml | 11 ++++++++--- .github/workflows/examples-python.yml | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/examples-js.yml b/.github/workflows/examples-js.yml index 70bb9bc..9327bbf 100644 --- a/.github/workflows/examples-js.yml +++ b/.github/workflows/examples-js.yml @@ -56,14 +56,19 @@ jobs: # Build owncast-plugin-test/serve from source and drop them into the # shared cache, replacing the released binaries postinstall fetched, so - # examples run against the current runtime. The runtime lives in owncast - # (services/plugins); GOPRIVATE + @develop matches release.yml. + # examples run against the current runtime. A matching branch in the + # Owncast repository takes precedence over develop for cross-repo stacks. - name: Build host binaries from source working-directory: host-runtime env: GOPRIVATE: github.com/owncast/owncast run: | - go get github.com/owncast/owncast@develop + owncast_ref=develop + if [[ -n "${GITHUB_HEAD_REF:-}" ]] && git ls-remote --exit-code --heads https://github.com/owncast/owncast.git "refs/heads/${GITHUB_HEAD_REF}" >/dev/null; then + owncast_ref="${GITHUB_HEAD_REF}" + fi + echo "Building against owncast@${owncast_ref}" + go get "github.com/owncast/owncast@${owncast_ref}" go build -o ../sdks/js/bin/.cache/owncast-plugin-test ./cmd/owncast-plugin-test go build -o ../sdks/js/bin/.cache/owncast-plugin-serve ./cmd/owncast-plugin-serve diff --git a/.github/workflows/examples-python.yml b/.github/workflows/examples-python.yml index 9e2fda1..d7128dc 100644 --- a/.github/workflows/examples-python.yml +++ b/.github/workflows/examples-python.yml @@ -39,14 +39,19 @@ jobs: go-version-file: host-runtime/go.mod cache-dependency-path: host-runtime/go.sum - # Build owncast-plugin-test/serve from the current owncast runtime - # (services/plugins). GOPRIVATE + @develop matches release.yml. + # Build owncast-plugin-test/serve from the current owncast runtime. + # A matching Owncast branch takes precedence over develop for stacked PRs. - name: Build host binaries from source working-directory: host-runtime env: GOPRIVATE: github.com/owncast/owncast run: | - go get github.com/owncast/owncast@develop + owncast_ref=develop + if [[ -n "${GITHUB_HEAD_REF:-}" ]] && git ls-remote --exit-code --heads https://github.com/owncast/owncast.git "refs/heads/${GITHUB_HEAD_REF}" >/dev/null; then + owncast_ref="${GITHUB_HEAD_REF}" + fi + echo "Building against owncast@${owncast_ref}" + go get "github.com/owncast/owncast@${owncast_ref}" mkdir -p "$GITHUB_WORKSPACE/pytoolchain/bin" "$GITHUB_WORKSPACE/pytoolchain/share" go build -o "$GITHUB_WORKSPACE/pytoolchain/bin/owncast-plugin-test" ./cmd/owncast-plugin-test go build -o "$GITHUB_WORKSPACE/pytoolchain/bin/owncast-plugin-serve" ./cmd/owncast-plugin-serve