-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
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
feat(notif-platform): Prototype the service layer #93863
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
4c991ff
to
6eeb981
Compare
6eeb981
to
2e873b5
Compare
…otificationplatform
be690a5
to
2ec5eed
Compare
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.
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 |
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.
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.
This comment was marked as off-topic.
Sorry, something went wrong.
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.
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
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.
Out of curiosity, what's the concrete mypy error you get in the former case?
# 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) |
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.
This is good scaffolding, but this will eventually be moved to a delivery task vs as part of the service.
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.
Good point! I will incorporate this into some comments in a follow up
Meant to add this comment here!
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) |
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.
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" |
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.
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.
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! |
def notify( | ||
self, | ||
*, | ||
strategy: NotificationStrategy | None = None, | ||
targets: list[NotificationTarget] | None = None, | ||
template: NotificationTemplate[T], |
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.
curious q, why are we combining the handling of both strategy and target type inputs in the sample place ?
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.
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 |
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.
should this be slack?
provider_key: NotificationProviderKey | ||
resource_type: NotificationTargetResourceType | ||
resource_id: str | ||
specific_data: dict[str, Any] | None |
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.
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) |
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.
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
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.
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
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 inprepare_targets
, which sidesteps the frozen data class to ensure they have all the data to send out to providers.Also changed
NotificationType
toNotificationCategory
since type is a keyword (and a bit overloaded)