Creating custom hooks
A Seagrass audit hook is just a class satisfying the
seagrass.base.ProtoHook interface. The easiest way to do this is to
create a new class that inherits from ProtoHook.
Here is a basic example of a hook that just prints the arguments given to the
prehook() and
posthook() methods:
from seagrass.base import ProtoHook
class ArgsHook(ProtoHook[None]):
def prehook(self, event_name, args, kwargs) -> None:
print(f"prehook: event_name={event_name!r}, args={args}, kwargs={kwargs}")
def posthook(self, event_name, result, context: None):
print(f"posthook: event_name={event_name!r}, result={result}, context={context}")
If you’re using a typechecker like mypy, note that the type argument to
ProtoHook is the type of the context returned by prehook (and used by
posthook).
This class satisfies the ProtoHook interface, so we can start using it to
hook events:
>>> from seagrass import Auditor
>>> auditor = Auditor()
>>> hook = ArgsHook()
>>> @auditor.audit("example.foo", hooks=[hook])
... def foo(x, y, z=0):
... print(f"x + y + z = {x + y + z}")
... return x + y + z
>>> with auditor.start_auditing():
... result = foo(2, -1, z=3)
prehook: event_name='example.foo', args=(2, -1), kwargs={'z': 3}
x + y + z = 4
posthook: event_name='example.foo', result=4, context=None
Passing context between the prehook and posthook
Sometimes, we may want to calculate something in the prehook and pass the result of our calculation to the posthook. For instance, consider an auditing hook that reports the amount of time that was spent executing an event; the posthook would need to know at what time the event started in order to figure out how much time elapsed.
For cases like these, you can return a context variable from the prehook; this
variable is passed directly to posthook. Here’s an example where we
implement the hook mentioned before: the prehook returns the time at which it
was launched, which the posthook uses to calculate the total time spent
executing an event:
>>> import time
>>> class ElapsedTimeHook(ProtoHook[float]):
... def prehook(self, event_name, args, kwargs) -> float:
... print(f"Getting start time for {event_name}...")
... return time.time()
...
... def posthook(self, event_name, result, context: float):
... elapsed = time.time() - context
... end = time.time()
... print(f"Time spent in {event_name}: {elapsed:.1f}s")
...
>>> hook = ElapsedTimeHook()
>>> ausleep = auditor.audit("event.sleep", time.sleep, hooks=[hook])
>>> with auditor.start_auditing():
... ausleep(0.1)
Getting start time for event.sleep...
Time spent in event.sleep: 0.1s
Change prehook and posthook priority
In some cases, it may make sense to have a hook run before or after other hooks
that have been assigned to an event. For instance, in our example above, we
probably want to have ElapsedTimeHook.prehook run after other
prehooks, and to have ElapsedTimeHook.posthook run before other
prehooks. This way, we wouldn’t calculate the amount of time spent in other
hooks towards the total amount of time spent in the event.
Their are two ways to change the order in which hooks are run:
Change the order of the
hookslist. When we callauditor.audit, hooks hooks that come at the end of the list have their prehooks run after and their posthooks run before other events in the list.Here’s what the output looks like if we put
ElapsedTimeHookafterArgsHook:>>> hooks = [ArgsHook(), ElapsedTimeHook()] >>> ausleep = auditor.audit("sleep_ex_1", time.sleep, hooks=hooks) >>> with auditor.start_auditing(): ... ausleep(0.1) prehook: event_name='sleep_ex_1', args=(0.1,), kwargs={} Getting start time for sleep_ex_1... Time spent in sleep_ex_1: 0.1s posthook: event_name='sleep_ex_1', result=None, context=None
And here’s the output if we put
ElapsedTimeHookbeforeArgsHook:>>> hooks = [ElapsedTimeHook(), ArgsHook()] >>> ausleep = auditor.audit("sleep_ex_2", time.sleep, hooks=hooks) >>> with auditor.start_auditing(): ... ausleep(0.1) Getting start time for sleep_ex_2... prehook: event_name='sleep_ex_2', args=(0.1,), kwargs={} posthook: event_name='sleep_ex_2', result=None, context=None Time spent in sleep_ex_2: 0.1s
Set a
priorityon your hooks (i.e.hook.priority). Hooks with high priority will have their prehook executed after and its posthook executed before hooks with lower priority.>>> th = ElapsedTimeHook() >>> ah = ArgsHook() >>> # Test with hook priority for ElapsedTimeHook >>> th.priority = 10 >>> ausleep = auditor.audit("priority_ex", time.sleep, hooks=[th, ah]) >>> with auditor.start_auditing(): ... ausleep(0.1) prehook: event_name='priority_ex', args=(0.1,), kwargs={} Getting start time for priority_ex... Time spent in priority_ex: 0.1s posthook: event_name='priority_ex', result=None, context=None
Additional hook methods
All hooks are required to define the methods specified by the
ProtoHook protocol class. In addition, Seagrass
defines a few other protocols that your hook can implement to get even more
functionality.
ResettableHook: an interface that should be implemented for hooks that have some kind of internal state that should be able to be reset.LogResultsHook: an interface for hooks whose results can be logged usingseagrass.Auditor.log_results().CleanupHook: an interface for hooks that have a “clean-up” stage that needs to be executed before an event is finished.
ResettableHook: resetting hooks with internal state
Sometimes, you may want to perform multiple auditing runs, and report the
results from each run. Here’s an example where we use
seagrass.hooks.CounterHook to count the number of times the event
"audit.foo" gets raised:
>>> from seagrass.hooks import CounterHook
>>> hook = CounterHook()
>>> ev_foo = auditor.create_event("audit.foo", hooks=[hook])
>>> with auditor.start_auditing():
... auditor.raise_event("audit.foo")
>>> auditor.log_results()
{"message": "CounterHook results", "seagrass": {"event": null, "hook": "CounterHook", "hook_ctx": {"event": "audit.foo", "count": 1}}, "level": "INFO"}
>>> with auditor.start_auditing():
... auditor.raise_event("audit.foo")
>>> auditor.log_results()
{"message": "CounterHook results", "seagrass": {"event": null, "hook": "CounterHook", "hook_ctx": {"event": "audit.foo", "count": 2}}, "level": "INFO"}
Notice that the second time we called log_results, it contained the results
for both the first auditing context and the second auditing context. If we want
to reset results between runs, we need to call hook.reset():
>>> hook.reset()
>>> with auditor.start_auditing():
... auditor.raise_event("audit.foo")
>>> auditor.log_results()
{"message": "CounterHook results", "seagrass": {"event": null, "hook": "CounterHook", "hook_ctx": {"event": "audit.foo", "count": 1}}, "level": "INFO"}
Alternatively, we could pass reset_hooks=True and log_results=True when
we call auditor.audit. This logs all hook results and then resets the hooks
when we leave the auditing context:
>>> hook.reset()
>>> with auditor.start_auditing(reset_hooks=True, log_results=True):
... auditor.raise_event("audit.foo")
{"message": "CounterHook results", "seagrass": {"event": null, "hook": "CounterHook", "hook_ctx": {"event": "audit.foo", "count": 1}}, "level": "INFO"}
>>> # Since the hooks were reset, log_results won't show any recorded events
>>> auditor.log_results()
{"message": "no events recorded by counter", "seagrass": {"event": null, "hook": "CounterHook", "hook_ctx": {}}, "level": "WARNING"}
A hook that implements the ResettableHook interface
by implementing reset() can be reset
using auditor.reset_hooks() or by passing reset_hooks=True into
auditor.start_auditing(). For most hooks that have some kind of mutable
internal state, you probably want to implement this interface.
LogResultsHook: logging your hook’s results
Hooks that implement the seagrass.base.LogResultsHook interface (in
addition to ProtoHook will also have their results
logged when auditor.log_results() is called.
>>> import time
>>> class TotalElapsedTimeHook(ProtoHook[float]):
... def __init__(self):
... self.ctr = 0.
...
... def prehook(self, event_name, args, kwargs) -> float:
... return time.time()
...
... def posthook(self, event_name, result, context: float):
... start_time = context
... self.ctr += time.time() - start_time
...
... def log_results(self, logger):
... logger.info("TotalElapsedTimeHook: elapsed time: %.1fs", self.ctr)
>>> hook = TotalElapsedTimeHook()
>>> time.sleep = auditor.audit("event.sleep", time.sleep, hooks=[hook])
>>> with auditor.start_auditing():
... time.sleep(0.1)
>>> auditor.log_results()
{"message": "TotalElapsedTimeHook: elapsed time: 0.1s", "seagrass": {"event": null}, "level": "INFO"}
CleanupHook: hooks with a cleanup stage
Some hooks may have side effects that need to be cleaned up after the hook is
executed. For instance, here is a hook that sets the CURRENT_EVENT global
variable to be the name of the current Seagrass event that is executing (or
None if no event is being executed):
>>> from seagrass.base import ProtoHook
>>> import typing as t
>>> CURRENT_EVENT: t.Optional[str] = None
>>> class BadCurrentEventHook(ProtoHook):
... def prehook(self, event_name, args, kwargs):
... global CURRENT_EVENT
... old_event = CURRENT_EVENT
... CURRENT_EVENT = event_name
... return old_event
...
... def posthook(self, event_name, result, context):
... global CURRENT_EVENT
... old_event = context
... CURRENT_EVENT = old_event
>>> hook = BadCurrentEventHook()
>>> print_event = lambda: print(f"CURRENT_EVENT={CURRENT_EVENT!r}")
>>> foo = auditor.audit("event.foo", print_event, hooks=[hook])
>>> bar = auditor.audit("event.bar", print_event, hooks=[hook])
>>> with auditor.start_auditing():
... foo()
... bar()
CURRENT_EVENT='event.foo'
CURRENT_EVENT='event.bar'
>>> print(CURRENT_EVENT)
None
However, what happens if an exception is raised while we’re running the event
that’s being executed? In that case, the posthook never executes, and
CURRENT_EVENT doesn’t get reset back to its old value:
>>> @auditor.audit("event.baz", hooks=[hook])
... def baz():
... raise RuntimeError()
>>> with auditor.start_auditing():
... baz()
Traceback (most recent call last):
RuntimeError:
>>> print(CURRENT_EVENT)
event.baz
What we should do instead is define a
cleanup() method so that our hook satisfies
the CleanupHook interface, and then reset the value
of CURRENT_EVENT in cleanup(). Unlike posthook, the cleanup
stage of a hook is called no matter what, so long as the hook’s prehook was
executed.
>>> import seagrass
>>> from seagrass.base import ProtoHook, CleanupHook
>>> class CurrentEventHook(ProtoHook):
... def prehook(self, event_name, args, kwargs):
... global CURRENT_EVENT
... old_event = CURRENT_EVENT
... CURRENT_EVENT = event_name
... return old_event
...
... def cleanup(self, event_name, context, exc):
... global CURRENT_EVENT
... old_event = context
... CURRENT_EVENT = old_event
>>> hook = CurrentEventHook()
>>> isinstance(hook, CleanupHook)
True
By deferring the part where we reset CURRENT_EVENT to the cleanup
function, we ensure that CURRENT_EVENT will always be reset even if an
exception is raised during the execution of the audited event:
>>> import seagrass
>>> @seagrass.audit("event.baz", hooks=[hook])
... def baz():
... raise RuntimeError()
>>> with seagrass.start_auditing():
... baz()
Traceback (most recent call last):
RuntimeError:
>>> print(CURRENT_EVENT)
None