Skip to content
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: Add get_chain_root_span utility for langchain instrumentation #1054

Merged
merged 31 commits into from
Oct 17, 2024

Conversation

anticorrelator
Copy link
Contributor

@anticorrelator anticorrelator commented Oct 7, 2024

partially resolves #4158
resolves #1052

Adds a utility to get the most recent chain span that can be found traversing up the trace tree. This will help grab the most appropriate span for sending user feedback.

@anticorrelator anticorrelator requested a review from a team as a code owner October 7, 2024 15:36
@dosubot dosubot bot added the size:S This PR changes 10-29 lines, ignoring generated files. label Oct 7, 2024
@RogerHYang
Copy link
Contributor

Can you please add a test similar to this one, and make sure it's working with concurrency? Thanks!

@anticorrelator anticorrelator force-pushed the dustin/track-langchain-root-spans branch from aefa72c to 298318a Compare October 7, 2024 16:10
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:S This PR changes 10-29 lines, ignoring generated files. labels Oct 7, 2024
@dosubot dosubot bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Oct 8, 2024
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Oct 9, 2024
), "Did not capture all root spans during execution"

assert (
len(set(id(span) for span in root_spans_during_execution)) == 2 * n
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. Shouldn't the number of root spans be n, i.e. one for each run, since there are n runs?
    • Otherwise this test is not really testing the tree climbing procedure.
    • Since we get ReadableSpan from in_memory_span_exporter, it's easy to determine which is root, i.e. when span.parent is None.
  2. Should we also check to see if root span_id is actually correct, in addition to counting them?
    • Otherwise it could just be any span (if we refactor the code), and we wouldn't be sure.
  3. Should we run the same test for the threaded version?
    • Right now the threaded test above is different.
    • Ideally we should ensure our implementation works for both types of concurrency.
Suggested change
len(set(id(span) for span in root_spans_during_execution)) == 2 * n
len(set(id(span) for span in root_spans_during_execution)) == n

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The RunnableLambdas create their own chain, so they are their own root

Copy link
Contributor

Choose a reason for hiding this comment

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

The RunnableLambdas create their own chain, so they are their own root

If that's the case then this is not really testing the tree climbing procedure. Is that correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not, though if like you suggested we yield the the ancestor chains we can test how many there are. I didn't end up doing that since it feels like a confusing interface?

Copy link
Contributor

Choose a reason for hiding this comment

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

This is not related to yielding the ancestor chain. All i'm pointing out is that we are not testing what we have implemented.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I know that, I'm suggesting yielding the ancestor chain can be an easy way to do that

Copy link
Contributor

Choose a reason for hiding this comment

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

I know that

So are you suggesting that the logic you have implemented so far doesn't need to be tested?

Copy link
Contributor

Choose a reason for hiding this comment

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

I’m not saying it has to be tested, but I’ve noticed the logic isn’t being tested. Since we seem to agree on that observation, I’d like to understand why leaving it untested is considered the better option.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am specifically offering an alternative strategy to testing whether or not it's the right ancestor directly: if we change this implementation to yield ancestors we can do (I think) a good enough test by counting how many ancestors are yielded each time instead of directly checking the ancestor directly.

Regarding needing to test it: I can go either way on it, I'm not positive this test is needed though I agree it would be nice. I'm using tests as a guide here to scaffold the implementation, not necessarily be exhaustive.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not positive this test is needed though I agree it would be nice. I'm using tests as a guide here to scaffold the implementation, not necessarily be exhaustive.

While I understand the test may not seem essential right now, it’s important to consider that this repo is updated only occasionally. Given the infrequent changes and the high likelihood that any future updates may be handled by someone new, having tests that are both thorough and precise can be incredibly beneficial. These tests can serve as reliable documentation and help prevent potential issues when the codebase is revisited later.

I am specifically offering an alternative strategy to testing

As an aside, I previously mentioned that the self.run_map from the base instrumentor already stores the family tree of UUIDs. You can leverage this to simplify both the implementation and the testing, as it’s the ultimate source of truth for the family tree.

@@ -64,6 +64,20 @@ def _uninstrument(self, **kwargs: Any) -> None:
def get_span(self, run_id: UUID) -> Optional[Span]:
return self._tracer.get_span(run_id) if self._tracer else None

def get_root_chain_span(self, run_id: UUID) -> Optional[Span]:
Copy link
Contributor

Choose a reason for hiding this comment

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

I think by making the following signature change, we could make this more general and at the same time simplify our internal logic, given the following compositional equivalence. This simplifies our backend logic because we only need a Dict[UUID, Optional[UUID]] to track whose parent is whom, and we can just call on the preexisting self.run_map.

get_root_chain_span(run_id) is get_span(get_root_chain_run_id(run_id))

We just have to also update get_span to take None as argument, which is a trivial change.

Suggested change
def get_root_chain_span(self, run_id: UUID) -> Optional[Span]:
def get_root_chain_run_id(self, run_id: UUID) -> Optional[UUID]:

Copy link
Contributor

Choose a reason for hiding this comment

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

FYI, self.run_map seems to be all you need.

if span_id in tracer._root_span_ids:
return span

span = tracer._parent_span_by_span_id.get(span_id) # get parent span
Copy link
Contributor

Choose a reason for hiding this comment

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

Strictly speaking, we should not be using private methods in this scope.

@@ -64,6 +64,19 @@ def _uninstrument(self, **kwargs: Any) -> None:
def get_span(self, run_id: UUID) -> Optional[Span]:
return self._tracer.get_span(run_id) if self._tracer else None

def get_root_chain_spans(self, run_id: UUID) -> Optional[List[Span]]:
Copy link
Contributor

Choose a reason for hiding this comment

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

Unless you disagree, “root” typically refers to just a single node, so naming the function that way would be misleading if it intends to return a list. Based on my reading, this function returns all ancestors that are chains, and skips those that are not, so if the final root of the tree is not a chain, it is actually not returned.

root_chain_spans.append(span)

span = tracer._parent_span_by_span_id.get(span_id) # get parent span
return root_chain_spans if root_chain_spans else None
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. might as well just return an empty list, since it makes no practical difference but simplifies the types.

Suggested change
return root_chain_spans if root_chain_spans else None
return root_chain_spans

span_id = span.get_span_context().span_id
tracer = self._tracer
assert tracer
if span_id in tracer._root_span_ids:
Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned earlier, in this function you could use the existing tracer.run_map from the base Tracer to simplify your implementation, rather than accessing private attributes in this scope. Since tracer.run_map already provides all the necessary functionality, there’s no need to re-implement everything from scratch: the parts highlighted by arrows in the screenshot below would give you the parent_run_id and run_type that you're using for your tree traversal algorithm.

Screenshot 2024-10-16 at 8 11 51 AM

Copy link
Contributor

Choose a reason for hiding this comment

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

Let's give it one stab to have one tree data structure to rule them all. It probably is worth it if we can do that.

Copy link
Contributor

@RogerHYang RogerHYang left a comment

Choose a reason for hiding this comment

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

Approved to unblock. Concerns are noted in comments

@dosubot dosubot bot added the lgtm This PR has been approved by a maintainer label Oct 16, 2024
@@ -64,6 +64,19 @@ def _uninstrument(self, **kwargs: Any) -> None:
def get_span(self, run_id: UUID) -> Optional[Span]:
return self._tracer.get_span(run_id) if self._tracer else None

def get_root_chain_spans(self, run_id: UUID) -> Optional[List[Span]]:
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe tying to "chain" might not be the best first approach. What about just get_ancestors which traverses up the run tree is best for now? then we can at least say the last one is "likely" to be a root?

I would also add a docstring here to explain it.

Copy link
Contributor

Choose a reason for hiding this comment

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

rationale for not tying to "chain" is because we might have things just be "agent" for disambiguation

span_id = span.get_span_context().span_id
tracer = self._tracer
assert tracer
if span_id in tracer._root_span_ids:
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's give it one stab to have one tree data structure to rule them all. It probably is worth it if we can do that.

@@ -64,6 +64,26 @@ def _uninstrument(self, **kwargs: Any) -> None:
def get_span(self, run_id: UUID) -> Optional[Span]:
return self._tracer.get_span(run_id) if self._tracer else None

def get_ancestors(self, run_id: UUID) -> Optional[List[Span]]:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
def get_ancestors(self, run_id: UUID) -> Optional[List[Span]]:
def get_ancestor_spans(self, run_id: UUID) -> Optional[List[Span]]:

just to be explicit about the return type


run = tracer.run_map.get(str(run_id))
run_id = run.parent_run_id
return ancestors if ancestors else None
Copy link
Contributor

Choose a reason for hiding this comment

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

I think for the simplicity of the typing (and maybe just my FP brain) - I think an empty array might be preferable to represent "none"?

@anticorrelator anticorrelator merged commit 4337aa1 into main Oct 17, 2024
3 checks passed
@anticorrelator anticorrelator deleted the dustin/track-langchain-root-spans branch October 17, 2024 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
lgtm This PR has been approved by a maintainer size:L This PR changes 100-499 lines, ignoring generated files.
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

[feature request] Add an easy way to get the root span from a child span
3 participants