Repository navigation
[examples] Bluesky/ATProto sample integration (input stream, rules) #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
469219b
2c7edba
1f6a8de
b28092f
c2c0746
7cccb4b
232c449
0c8f462
260e462
6bf2841
74ae20d
b650d94
58d1bcf
853aa74
2cdb462
a591e1c
bd44b1d
ff18575
fbee1a8
f5b23ff
c959238
36b0e7c
7ceba0c
b6ef2c4
22103e9
4356412
bef1703
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # Override that swaps the synthetic Kafka producer for the live Bluesky JetStream | ||
| # firehose, via the example_atproto_plugins package. Stack on top of the main | ||
| # compose file: | ||
| # | ||
| # docker compose -f docker-compose.yaml -f docker-compose.atproto.yaml up | ||
| # | ||
| # Or use the convenience wrapper: ./run-atproto.sh | ||
| services: | ||
| osprey-worker: | ||
| environment: | ||
| OSPREY_INPUT_STREAM_SOURCE: plugin | ||
| OSPREY_RULES_PATH: ./example_atproto_rules | ||
| volumes: | ||
| - ./example_atproto_rules:/osprey/example_atproto_rules | ||
| - ./example_atproto_plugins:/osprey/example_atproto_plugins | ||
|
|
||
| osprey-ui-api: | ||
| environment: | ||
| OSPREY_RULES_PATH: /osprey/example_atproto_rules | ||
| volumes: | ||
| - ./example_atproto_rules:/osprey/example_atproto_rules |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # example_atproto_plugins | ||
|
|
||
| A sample Osprey plugin that consumes ATProto's [JetStream](https://docs.bsky.app/blog/jetstream) as the input event source. It gives you: | ||
|
|
||
| - a `register_input_stream` hook implementation that subscribes to JetStream over WebSocket and yields Osprey `Action`s with the JetStream JSON event passed through as-is, | ||
| - realistic per-second event volume from the live Bluesky network, which is useful for load and soak testing changes that the synthetic 1-event/second producer doesn't exercise, | ||
| - a companion `example_atproto_rules/` tree showing how to organize rules against ATProto event shapes, with file structure modeled on [haileyok/atproto-ruleset](https://github.com/haileyok/atproto-ruleset). | ||
|
|
||
| ## Running | ||
|
|
||
| From the repo root: | ||
|
|
||
| ```sh | ||
| ./run-atproto.sh | ||
| ``` | ||
|
|
||
| This brings up the full Osprey local stack (Druid, Postgres, MinIO, Kafka) along with a JetStream websocket override, and swaps the worker's input source from Kafka to the JetStream plugin, pointing it at `example_atproto_rules` instead of `example_rules`. First-run startup takes a few minutes. | ||
|
|
||
| ## Configuration | ||
|
|
||
| | Env var | Default | Description | | ||
| | --- | --- | --- | | ||
| | `OSPREY_INPUT_STREAM_SOURCE` | (must be) `plugin` | Selects the plugin-provided stream. | | ||
| | `OSPREY_JETSTREAM_ENDPOINT` | `wss://jetstream2.us-west.bsky.network/subscribe` | JetStream WebSocket URL. | | ||
| | `OSPREY_JETSTREAM_WANTED_COLLECTIONS` | `app.bsky.feed.post,app.bsky.feed.like,app.bsky.feed.repost,app.bsky.graph.follow,app.bsky.actor.profile` | Comma-separated collections to subscribe to (server-side filter). | | ||
|
|
||
| ## Action shape | ||
|
|
||
| The JetStream JSON event is passed through unchanged as the Action's `data` dict, so rules read JetStream-native paths directly. `action_name` is `<operation>_<short>` for commit events (`create_post`, `delete_like`, `update_profile`, …) using the short names defined in `COLLECTION_NAMES`, or `identity` for identity events. | ||
|
|
||
| ### Commit events (e.g. `create_post`, `delete_like`) | ||
|
|
||
| ``` | ||
| { | ||
| "did": "did:plc:...", | ||
| "time_us": 1714500000000000, | ||
| "kind": "commit", | ||
| "commit": { | ||
| "rev": "...", | ||
| "operation": "create" | "update" | "delete", | ||
| "collection": "app.bsky.feed.post", | ||
| "rkey": "...", | ||
| "cid": "...", | ||
| "record": { ... raw ATProto record ... } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Identity events (`action_name='identity'`) | ||
|
|
||
| ``` | ||
| { | ||
| "did": "did:plc:...", | ||
| "time_us": ..., | ||
| "kind": "identity", | ||
| "identity": {"did": "...", "handle": "...", "seq": ..., "time": "..."} | ||
| } | ||
| ``` | ||
|
|
||
| Account events, commits for collections not in `COLLECTION_NAMES`, and commits with operations other than `create` / `update` / `delete` are skipped. | ||
|
|
||
| ### UI default features | ||
|
|
||
| `example_atproto_rules/config/ui_config.yaml` declares the per-action default features the Osprey UI surfaces in the event stream — e.g. `PostText` for `create_post`, `IdentityHandle` for `identity`, `Subject` for like / repost / follow events. Add new entries there to expose more fields without touching rule code. | ||
|
|
||
| `action_id` is minted from `snowflake-id-worker` in batches of 250. The plugin therefore needs `SNOWFLAKE_API_ENDPOINT` to be set (the local docker-compose stack provides it). | ||
|
|
||
| ## Caveats | ||
|
|
||
| - **Not production-ready.** No durable cursor on process restart, no zstd compression, no DID-level filtering. Good for sample / load-testing purposes; not a drop-in for a real ATProto deployment. | ||
| - **No event enrichment.** JetStream only carries what's in the commit itself; rulesets that depend on handle / profile / account age (such as much of [atproto-ruleset](https://github.com/haileyok/atproto-ruleset)) are fed by a separate enrichment pipeline, not JetStream directly. This plugin emits JetStream-native paths ($.did, $.commit.collection, etc.); enrichment-fed rulesets would need an enrichment service in front of this one or a different plugin. | ||
| - **No application-level keepalive.** Connection health is monitored via socket read timeout (30s). A stalled connection will be detected and trigger reconnection. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| [project] | ||
| name = "example_atproto_plugins" | ||
| version = "0.1.0" | ||
| description = "Example Osprey plugin that consumes Bluesky's ATProto JetStream firehose" | ||
| requires-python = ">=3.11" | ||
| dependencies = [ | ||
| "pluggy==1.5.0", | ||
| "websocket-client==1.8.0", | ||
| ] | ||
|
|
||
| [tool.setuptools] | ||
| package-dir = {"" = "src"} | ||
|
|
||
| [tool.setuptools.packages.find] | ||
| where = ["src"] | ||
|
|
||
| [project.entry-points.osprey_plugin] | ||
| atproto_plugins = "atproto_plugin.register_plugins" | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,131 @@ | ||||||||||||||||
| import json | ||||||||||||||||
| import time | ||||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||||
| from typing import Any, Dict, Iterator, List, Optional | ||||||||||||||||
| from urllib.parse import urlencode | ||||||||||||||||
|
|
||||||||||||||||
| import sentry_sdk | ||||||||||||||||
| import websocket | ||||||||||||||||
| from osprey.engine.executor.execution_context import Action | ||||||||||||||||
| from osprey.worker.lib.instruments import metrics | ||||||||||||||||
| from osprey.worker.lib.osprey_shared.logging import get_logger | ||||||||||||||||
| from osprey.worker.lib.snowflake import generate_snowflake_batch | ||||||||||||||||
| from osprey.worker.sinks.sink.input_stream import BaseInputStream | ||||||||||||||||
| from osprey.worker.sinks.utils.acking_contexts import BaseAckingContext, NoopAckingContext | ||||||||||||||||
| from websocket import WebSocketConnectionClosedException | ||||||||||||||||
|
|
||||||||||||||||
| logger = get_logger() | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| DEFAULT_ENDPOINT = 'wss://jetstream2.us-west.bsky.network/subscribe' | ||||||||||||||||
| COLLECTION_NAMES = { | ||||||||||||||||
| 'app.bsky.feed.post': 'post', | ||||||||||||||||
| 'app.bsky.feed.like': 'like', | ||||||||||||||||
| 'app.bsky.feed.repost': 'repost', | ||||||||||||||||
| 'app.bsky.graph.follow': 'follow', | ||||||||||||||||
| 'app.bsky.actor.profile': 'profile', | ||||||||||||||||
| } | ||||||||||||||||
| DEFAULT_COLLECTIONS = tuple(COLLECTION_NAMES.keys()) | ||||||||||||||||
| SNOWFLAKE_BATCH_SIZE = 250 | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class JetStreamInputStream(BaseInputStream[BaseAckingContext[Action]]): | ||||||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is the main thing i'd love to get eyes on if anyone has python websocket experience...it doesn't need to be production stable since its really a testing/example guy (not to mention jetstream shouldn't be used for real-world moderation tasks anyway) but it should be at least mostly stable...
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i did want to opt for a non-kafka option in here, because doing this helps break the misconception that osprey can only be used with kafka |
||||||||||||||||
| """An Osprey event input stream that subscribes to the ATProto JetStream websocket and yields | ||||||||||||||||
| Osprey actions. | ||||||||||||||||
|
|
||||||||||||||||
| The JetStream JSON event is passed through unchanged as the Action's data dict, so rules may | ||||||||||||||||
| target the JetStream-native paths directly, like $.did, $.kind, or $.commit.operation. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| def __init__( | ||||||||||||||||
| self, | ||||||||||||||||
| endpoint: Optional[str] = None, | ||||||||||||||||
| wanted_collections: Optional[List[str]] = None, | ||||||||||||||||
| reconnect_seconds: float = 2.0, | ||||||||||||||||
| ): | ||||||||||||||||
| super().__init__() | ||||||||||||||||
| self._endpoint = endpoint or DEFAULT_ENDPOINT | ||||||||||||||||
| self._wanted_collections = list(wanted_collections) if wanted_collections else list(DEFAULT_COLLECTIONS) | ||||||||||||||||
| self._reconnect_seconds = reconnect_seconds | ||||||||||||||||
| self._last_time_us: Optional[int] = None | ||||||||||||||||
| self._snowflake_buffer: List[int] = [] | ||||||||||||||||
|
|
||||||||||||||||
| def _next_action_id(self) -> int: | ||||||||||||||||
| if not self._snowflake_buffer: | ||||||||||||||||
| batch = generate_snowflake_batch(count=SNOWFLAKE_BATCH_SIZE, retries=3) | ||||||||||||||||
| self._snowflake_buffer = [s.to_int() for s in batch] | ||||||||||||||||
| return self._snowflake_buffer.pop() | ||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||||||||||||||||
|
|
||||||||||||||||
| def _build_url(self) -> str: | ||||||||||||||||
| params = [('wantedCollections', c) for c in self._wanted_collections] | ||||||||||||||||
| if self._last_time_us is not None: | ||||||||||||||||
| params.append(('cursor', str(self._last_time_us))) | ||||||||||||||||
| return f'{self._endpoint}?{urlencode(params)}' | ||||||||||||||||
|
|
||||||||||||||||
| def _gen(self) -> Iterator[BaseAckingContext[Action]]: | ||||||||||||||||
| while True: | ||||||||||||||||
|
Comment on lines
+69
to
+70
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should provably want to add a backoff. If JetStream is down or rate-limiting, you reconnect every 2 seconds in a tight loop and trigger escalating errors, potential higher rate limits and sentry capture on every event. We can start with a simple |
||||||||||||||||
| try: | ||||||||||||||||
| url = self._build_url() | ||||||||||||||||
| yield from self._stream_one_connection(url) | ||||||||||||||||
| except Exception as e: | ||||||||||||||||
| logger.exception(f'JetStream stream error; reconnecting in {self._reconnect_seconds}s: {e}') | ||||||||||||||||
| sentry_sdk.capture_exception(e) | ||||||||||||||||
| time.sleep(self._reconnect_seconds) | ||||||||||||||||
|
|
||||||||||||||||
| def _stream_one_connection(self, url: str) -> Iterator[BaseAckingContext[Action]]: | ||||||||||||||||
| logger.info(f'Connecting to JetStream at {url}') | ||||||||||||||||
| ws = websocket.create_connection(url, timeout=30) | ||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Meaning there is no ping interval ensuring the connection stays alive. So today the only thing keeping the connection healthy is JetStream's volume + the read timeout. Here you can either spawn a daemon to health ping the socket. Or better yet use docs explain my thought here. |
||||||||||||||||
| logger.info('JetStream connection established') | ||||||||||||||||
| try: | ||||||||||||||||
| while True: | ||||||||||||||||
| try: | ||||||||||||||||
| raw = ws.recv() | ||||||||||||||||
| except WebSocketConnectionClosedException: | ||||||||||||||||
| logger.info('JetStream connection closed; will reconnect') | ||||||||||||||||
| return | ||||||||||||||||
| if not raw: | ||||||||||||||||
| continue | ||||||||||||||||
| try: | ||||||||||||||||
| event = json.loads(raw) | ||||||||||||||||
| action = _event_to_action(event, action_id=self._next_action_id()) | ||||||||||||||||
| except Exception: | ||||||||||||||||
| logger.exception('skipping malformed JetStream event') | ||||||||||||||||
| sentry_sdk.capture_exception() | ||||||||||||||||
| continue | ||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
we should be more specific on the error. |
||||||||||||||||
| if action is None: | ||||||||||||||||
| continue | ||||||||||||||||
| time_us = event.get('time_us') | ||||||||||||||||
| if time_us and isinstance(time_us, int) and time_us > 0: | ||||||||||||||||
| self._last_time_us = time_us | ||||||||||||||||
| metrics.increment('jetstream_input_stream.events', tags=[f'action_name:{action.action_name}']) | ||||||||||||||||
| yield NoopAckingContext(action) | ||||||||||||||||
| finally: | ||||||||||||||||
| try: | ||||||||||||||||
| ws.close() | ||||||||||||||||
| except Exception: | ||||||||||||||||
| logger.info('ignored error while closing JetStream socket', exc_info=True) | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def _event_to_action(event: Dict[str, Any], action_id: int) -> Optional[Action]: | ||||||||||||||||
| """Wraps a JetStream event as an Osprey action, or returns None if it should be skipped.""" | ||||||||||||||||
| kind = event.get('kind') | ||||||||||||||||
| if kind not in ('commit', 'identity'): | ||||||||||||||||
| return None | ||||||||||||||||
| time_us = event.get('time_us') | ||||||||||||||||
| if not isinstance(time_us, int) or time_us <= 0: | ||||||||||||||||
| return None | ||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||||||||||||||||
| if kind == 'commit': | ||||||||||||||||
| commit = event.get('commit') or {} | ||||||||||||||||
| operation = commit.get('operation') | ||||||||||||||||
| short = COLLECTION_NAMES.get(commit.get('collection', '')) | ||||||||||||||||
| if short is None or operation not in ('create', 'update', 'delete'): | ||||||||||||||||
| return None | ||||||||||||||||
| action_name = f'{operation}_{short}' | ||||||||||||||||
| else: | ||||||||||||||||
| action_name = 'identity' | ||||||||||||||||
| return Action( | ||||||||||||||||
| action_id=action_id, | ||||||||||||||||
| action_name=action_name, | ||||||||||||||||
| data=event, | ||||||||||||||||
| timestamp=datetime.fromtimestamp(time_us / 1_000_000, tz=timezone.utc), | ||||||||||||||||
| ) | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| from osprey.engine.executor.execution_context import Action | ||
| from osprey.worker.adaptor.plugin_manager import hookimpl_osprey | ||
| from osprey.worker.lib.config import Config | ||
| from osprey.worker.sinks.sink.input_stream import BaseInputStream | ||
| from osprey.worker.sinks.utils.acking_contexts import BaseAckingContext | ||
|
|
||
| from atproto_plugin.jetstream_input_stream import JetStreamInputStream | ||
|
|
||
|
|
||
| @hookimpl_osprey | ||
| def register_input_stream(config: Config) -> BaseInputStream[BaseAckingContext[Action]]: | ||
| endpoint = config.get_optional_str('OSPREY_JETSTREAM_ENDPOINT') | ||
| raw_collections = config.get_optional_str('OSPREY_JETSTREAM_WANTED_COLLECTIONS') | ||
| wanted = [c.strip() for c in raw_collections.split(',') if c.strip()] if raw_collections else None | ||
| return JetStreamInputStream(endpoint=endpoint, wanted_collections=wanted) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: roostorg/osprey
Length of output: 202
🏁 Script executed:
Repository: roostorg/osprey
Length of output: 4819
Require approval for
websocket-client==1.8.0before merge. It’s a new dependency and needs license/CVE review;pluggy==1.5.0is already covered by the shared deps.🤖 Prompt for AI Agents
Source: Path instructions