Skip to content

feat(notif-platform): Prototype the service layer #93863

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

Merged

Conversation

leeandher
Copy link
Member

@leeandher leeandher commented Jun 18, 2025

Prototyping the service layer, with some added boilerplate for targets.

They'll need to have the integration data attached by the time we call .notify() so I've added a step in prepare_targets, which sidesteps the frozen data class to ensure they have all the data to send out to providers.

Also changed NotificationType to NotificationCategory since type is a keyword (and a bit overloaded)

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 18, 2025

This comment was marked as outdated.

@leeandher leeandher force-pushed the leander/eco-722-spike-on-type-safety-for-notificationplatform branch from 4c991ff to 6eeb981 Compare June 19, 2025 18:55
@leeandher leeandher force-pushed the leander/eco-722-spike-on-type-safety-for-notificationplatform branch from 6eeb981 to 2e873b5 Compare June 19, 2025 18:58
@leeandher leeandher force-pushed the leander/eco-722-spike-on-type-safety-for-notificationplatform branch from be690a5 to 2ec5eed Compare June 24, 2025 19:13
Copy link
Member

@GabeVillalobos GabeVillalobos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good for initial scaffolding, but I think we need to focus on Template & data design a bit more as a concept, focusing on the DX of it.

Generally: what should I as a dev have to define? Can I intuit this from another notification's configuration, and is this consolidated in some way?


class NotificationService[T: NotificationData]:
def __init__(self, *, data: T):
self.data: Final[T] = data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of binding the data to the service instance? From a DX perspective, wouldn't it make more sense to bind the template & type, then take in different data chunks? (i.e. user data + targets)?

This comment was marked as off-topic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong reply, I meant to respond that the reason I did it this way was so that the type safety would narrow the generic for the rest of the calls. It is what would allow the app code to error out if you did

notif = NotificationService(data=billing_data)
notif.send(template=alert_template) # mypy raises
notif.send(tempalte=billing_template) # all good

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, what's the concrete mypy error you get in the former case?

Comment on lines +46 to +55
# Step 3: Get the provider, and validate the target against it
provider = provider_registry.get(target.provider_key)
provider.validate_target(target=target)

# Step 4: Render the template
renderer = provider.get_renderer(category=self.data.category)
renderable = renderer.render(data=self.data, template=template)

# Step 5: Send the notification
provider.send(target=target, renderable=renderable)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good scaffolding, but this will eventually be moved to a delivery task vs as part of the service.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I will incorporate this into some comments in a follow up

Meant to add this comment here!

Comment on lines +65 to +83
if not strategy and not targets:
raise NotificationServiceError(
"Must provide either a strategy or targets. Strategy is preferred."
)
if strategy and targets:
raise NotificationServiceError(
"Cannot provide both strategy and targets, only one is permitted. Strategy is preferred."
)
if strategy:
targets = strategy.get_targets()
if not targets:
logger.info("Strategy '%s' did not yield targets", strategy.__class__.__name__)
return

# Prepare the targets for sending by fetching integration data, etc.
prepare_targets(targets=targets)

for target in targets:
self.notify_prepared_target(target=target, template=template)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I'm a big fan of having single target & batch targeting with strategies.

The exception is the `DEBUG` category, which is used for testing and development.
"""

DEBUG = "debug"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to chat more about this in a sync, because this can have some broad implications. I like the idea of meta groups, but we may want to do something like our Outbox groupings, where we bind things to scopes in a larger config somewhere. Additionally, I think we should consider binding some of these enums/groupings to the template rather than the data.

@leeandher
Copy link
Member Author

Changes look good for initial scaffolding, but I think we need to focus on Template & data design a bit more as a concept, focusing on the DX of it.

Generally: what should I as a dev have to define? Can I intuit this from another notification's configuration, and is this consolidated in some way?

Totally agree! this service layer was mostly focused on layout out the steps, but the next PR will focus on the templates and i'll touch on DX in more detail, ty for review!

@leeandher leeandher merged commit cc85440 into master Jun 25, 2025
64 checks passed
@leeandher leeandher deleted the leander/eco-722-spike-on-type-safety-for-notificationplatform branch June 25, 2025 20:45
Comment on lines +57 to +62
def notify(
self,
*,
strategy: NotificationStrategy | None = None,
targets: list[NotificationTarget] | None = None,
template: NotificationTemplate[T],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious q, why are we combining the handling of both strategy and target type inputs in the sample place ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So strategies are preferred but there are certain notif types that might pick specific targets, so this allows both. It's possible that we don't allow targets if we have enough strategies to cover all bases, but this gives us some flexibilty.

@@ -17,10 +18,12 @@


class SlackRenderer(NotificationRenderer[SlackRenderable]):
provider_key = NotificationProviderKey.SLACK
provider_key = NotificationProviderKey.DISCORD
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be slack?

provider_key: NotificationProviderKey
resource_type: NotificationTargetResourceType
resource_id: str
specific_data: dict[str, Any] | None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q what would data for this field look like?

"""
Adds necessary properties and methods to designate a target within an integration.
Accepts the renderable object type that matches the connected provider.
"""

integration_id: int
organization_id: int
integration: RpcIntegration = field(init=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q im kinda confused on the flow of this target class & prepare_targets? Is the scope of a single IntegrationNotificationTarget a everything under a single OrganizationIntegration ? I guess I'm confused on how these properties on the target work if there's potentially multiple OrganizationIntegrations attached to an Integration

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GenericNotificationTargets have all they need to be used to send a notification immediately, but the IntegrationNotificationTargets are initialized with data but not all the meta stuff like API keys. For example, an alert might have the integration_id and an organization_id and a slack channel, but to actually notify that channel, we'll need integration.metadata.token or something along those lines.

So at some point we have to do a DB query for the credentials, and for performance reasons we wanna batch this (e.g. if we send 50 notifications, we dont want 50 DB/RPC calls), so thats where the prepare_targets comes from. The app code passes in targets, unprepared, and the prepare_targets function will do the DB query or RPC call in bulk to add the integration or org_integration RPC object to the targets, then each can have notify() called with no side-effects like RPC/DB calls.

kinda confusing, lmk if it doesnt make sense

@github-actions github-actions bot locked and limited conversation to collaborators Jul 11, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Scope: Backend Automatically applied to PRs that change backend components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants