Skip to content

Commit

Permalink
capture stack trace for tracing spans (#2673)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Arnaldi <michael.arnaldi@effectful.co>
  • Loading branch information
2 people authored and gcanti committed May 16, 2024
1 parent 70163bd commit bf24c50
Show file tree
Hide file tree
Showing 30 changed files with 699 additions and 380 deletions.
7 changes: 7 additions & 0 deletions .changeset/cyan-kiwis-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"effect": minor
---

add Cause.prettyErrors api

You can use this to extract `Error` instances from a `Cause`, that have clean stack traces and have had span information added to them.
5 changes: 5 additions & 0 deletions .changeset/few-bikes-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

add span stack trace to rendered causes
19 changes: 19 additions & 0 deletions .changeset/lovely-frogs-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"effect": minor
---

add Effect.functionWithSpan

Allows you to define an effectful function that is wrapped with a span.

```ts
import { Effect } from "effect"

const getTodo = Effect.functionWithSpan({
body: (id: number) => Effect.succeed(`Got todo ${id}!`),
options: (id) => ({
name: `getTodo-${id}`,
attributes: { id }
})
})
```
9 changes: 9 additions & 0 deletions .changeset/tiny-zebras-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"effect": minor
"@effect/experimental": patch
"@effect/platform": patch
"@effect/rpc": patch
"@effect/sql": patch
---

capture stack trace for tracing spans
5 changes: 5 additions & 0 deletions .changeset/warm-clouds-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/opentelemetry": patch
---

properly record exceptions in otel spans
17 changes: 17 additions & 0 deletions packages/effect/src/Cause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { Pipeable } from "./Pipeable.js"
import type { Predicate, Refinement } from "./Predicate.js"
import type * as Sink from "./Sink.js"
import type * as Stream from "./Stream.js"
import type { Span } from "./Tracer.js"
import type { Covariant, NoInfer } from "./Types.js"

/**
Expand Down Expand Up @@ -914,6 +915,22 @@ export const isUnknownException: (u: unknown) => u is UnknownException = core.is
*/
export const pretty: <E>(cause: Cause<E>) => string = internal.pretty

/**
* @since 3.2.0
* @category models
*/
export interface PrettyError extends Error {
readonly span: Span | undefined
}

/**
* Returns the specified `Cause` as a pretty-printed string.
*
* @since 3.2.0
* @category rendering
*/
export const prettyErrors: <E>(cause: Cause<E>) => Array<PrettyError> = internal.prettyErrors

/**
* Returns the original, unproxied, instance of a thrown error
*
Expand Down
40 changes: 40 additions & 0 deletions packages/effect/src/Effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5364,6 +5364,46 @@ export const withSpan: {
): Effect<A, E, Exclude<R, Tracer.ParentSpan>>
} = effect.withSpan

/**
* Wraps a function that returns an effect with a new span for tracing.
*
* @since 3.2.0
* @category models
*/
export interface FunctionWithSpanOptions {
readonly name: string
readonly attributes?: Record<string, unknown> | undefined
readonly links?: ReadonlyArray<Tracer.SpanLink> | undefined
readonly parent?: Tracer.AnySpan | undefined
readonly root?: boolean | undefined
readonly context?: Context.Context<never> | undefined
readonly kind?: Tracer.SpanKind | undefined
}

/**
* Wraps a function that returns an effect with a new span for tracing.
*
* @since 3.2.0
* @category tracing
* @example
* import { Effect } from "effect"
*
* const getTodo = Effect.functionWithSpan({
* body: (id: number) => Effect.succeed(`Got todo ${id}!`),
* options: (id) => ({
* name: `getTodo-${id}`,
* attributes: { id }
* })
* })
*/
export const functionWithSpan: <Args extends Array<any>, Ret extends Effect<any, any, any>>(
options: {
readonly body: (...args: Args) => Ret
readonly options: FunctionWithSpanOptions | ((...args: Args) => FunctionWithSpanOptions)
readonly captureStackTrace?: boolean | undefined
}
) => (...args: Args) => Unify.Unify<Ret> = effect.functionWithSpan

/**
* Wraps the effect with a new span for tracing.
*
Expand Down
1 change: 1 addition & 0 deletions packages/effect/src/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface SpanOptions {
readonly root?: boolean | undefined
readonly context?: Context.Context<never> | undefined
readonly kind?: SpanKind | undefined
readonly captureStackTrace?: boolean | string | undefined
}

/**
Expand Down
115 changes: 62 additions & 53 deletions packages/effect/src/internal/cause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import * as HashSet from "../HashSet.js"
import { NodeInspectSymbol, toJSON } from "../Inspectable.js"
import * as Option from "../Option.js"
import { pipeArguments } from "../Pipeable.js"
import { hasProperty, isFunction } from "../Predicate.js"
import type { Predicate, Refinement } from "../Predicate.js"
import { hasProperty, isFunction } from "../Predicate.js"
import type { AnySpan, Span } from "../Tracer.js"
import type { NoInfer } from "../Types.js"
import { getBugErrorMessage } from "./errors.js"
Expand Down Expand Up @@ -968,53 +968,36 @@ export const reduceWithContext = dual<
// Pretty Printing
// -----------------------------------------------------------------------------

const filterStack = (stack: string) => {
const lines = stack.split("\n")
const out: Array<string> = []
for (let i = 0; i < lines.length; i++) {
out.push(lines[i].replace(/at .*effect_instruction_i.*\((.*)\)/, "at $1"))
if (lines[i].includes("effect_instruction_i")) {
return out.join("\n")
}
}
return out.join("\n")
}

/** @internal */
export const pretty = <E>(cause: Cause.Cause<E>): string => {
if (isInterruptedOnly(cause)) {
return "All fibers interrupted without errors."
}
const final = prettyErrors<E>(cause).map((e) => {
let message = e.message
if (e.stack) {
message += `\r\n${filterStack(e.stack)}`
}
if (e.span) {
let current: Span | AnySpan | undefined = e.span
let i = 0
while (current && current._tag === "Span" && i < 10) {
message += `\r\n at ${current.name}`
current = Option.getOrUndefined(current.parent)
i++
}
}
return message
}).join("\r\n")
return final
return prettyErrors<E>(cause).map((e) => e.stack).join("\n")
}

class PrettyError {
constructor(
readonly message: string,
readonly stack: string | undefined,
readonly span: Span | undefined
) {}
class PrettyError extends globalThis.Error implements Cause.PrettyError {
constructor(originalError: unknown) {
const prevLimit = Error.stackTraceLimit
Error.stackTraceLimit = 0
super(prettyErrorMessage(originalError), { cause: originalError })
Error.stackTraceLimit = prevLimit
this.name = originalError instanceof Error ? originalError.name : "Error"
this.stack = prettyErrorStack(
this.message,
originalError instanceof Error && originalError.stack
? originalError.stack
: "",
this.span
)
}

get span(): Span | undefined {
return hasProperty(this.cause, spanSymbol) ? this.cause[spanSymbol] as Span : undefined
}

toJSON() {
const out: any = { message: this.message }
if (this.stack) {
out.stack = this.stack
}
const out: any = { message: this.message, stack: this.stack }
if (this.span) {
out.span = this.span
}
Expand Down Expand Up @@ -1058,29 +1041,55 @@ export const prettyErrorMessage = (u: unknown): string => {
return `Error: ${JSON.stringify(u)}`
}

const spanSymbol = Symbol.for("effect/SpanAnnotation")
const locationRegex = /\((.*)\)/

const prettyErrorStack = (message: string, stack: string, span?: Span | undefined): string => {
const out: Array<string> = [message]
const lines = stack.split("\n")

const defaultRenderError = (error: unknown): PrettyError => {
const span: any = hasProperty(error, spanSymbol) && error[spanSymbol]
if (error instanceof Error) {
return new PrettyError(
prettyErrorMessage(error),
error.stack?.split("\n").filter((_) => _.match(/at (.*)/)).join("\n"),
span
for (let i = 1; i < lines.length; i++) {
if (lines[i].includes("effect_cutpoint")) {
break
}
out.push(
lines[i].replace(/at .*effect_instruction_i.*\((.*)\)/, "at $1").replace(/EffectPrimitive\.\w+/, "<anonymous>")
)
if (lines[i].includes("effect_instruction_i")) {
break
}
}
return new PrettyError(prettyErrorMessage(error), void 0, span)

if (span) {
let current: Span | AnySpan | undefined = span
let i = 0
while (current && current._tag === "Span" && i < 10) {
const stack = current.attributes.get("code.stacktrace")
if (typeof stack === "string") {
const locationMatch = stack.match(locationRegex)
const location = locationMatch ? locationMatch[1] : stack.replace(/^at /, "")
out.push(` at ${current.name} (${location})`)
} else {
out.push(` at ${current.name}`)
}
current = Option.getOrUndefined(current.parent)
i++
}
}

return out.join("\n")
}

const spanSymbol = Symbol.for("effect/SpanAnnotation")

/** @internal */
export const prettyErrors = <E>(cause: Cause.Cause<E>): ReadonlyArray<PrettyError> =>
export const prettyErrors = <E>(cause: Cause.Cause<E>): Array<PrettyError> =>
reduceWithContext(cause, void 0, {
emptyCase: (): ReadonlyArray<PrettyError> => [],
emptyCase: (): Array<PrettyError> => [],
dieCase: (_, unknownError) => {
return [defaultRenderError(unknownError)]
return [new PrettyError(unknownError)]
},
failCase: (_, error) => {
return [defaultRenderError(error)]
return [new PrettyError(error)]
},
interruptCase: () => [],
parallelCase: (_, l, r) => [...l, ...r],
Expand Down
46 changes: 32 additions & 14 deletions packages/effect/src/internal/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2310,29 +2310,47 @@ export const updateService = dual<
)))

/** @internal */
export const withSpan = dual<
export const withSpan: {
(
name: string,
options?: Tracer.SpanOptions
) => <OutElem, InElem, OutErr, InErr, OutDone, InDone, Env>(
): <OutElem, InElem, OutErr, InErr, OutDone, InDone, Env>(
self: Channel.Channel<OutElem, InElem, OutErr, InErr, OutDone, InDone, Env>
) => Channel.Channel<OutElem, InElem, OutErr, InErr, OutDone, InDone, Exclude<Env, Tracer.ParentSpan>>,
) => Channel.Channel<OutElem, InElem, OutErr, InErr, OutDone, InDone, Exclude<Env, Tracer.ParentSpan>>
<OutElem, InElem, OutErr, InErr, OutDone, InDone, Env>(
self: Channel.Channel<OutElem, InElem, OutErr, InErr, OutDone, InDone, Env>,
name: string,
options?: Tracer.SpanOptions
) => Channel.Channel<OutElem, InElem, OutErr, InErr, OutDone, InDone, Exclude<Env, Tracer.ParentSpan>>
>(3, (self, name, options) =>
unwrapScoped(
Effect.flatMap(
Effect.context(),
(context) =>
Effect.map(
Effect.makeSpanScoped(name, options),
(span) => core.provideContext(self, Context.add(context, tracer.spanTag, span))
)
): Channel.Channel<OutElem, InElem, OutErr, InErr, OutDone, InDone, Exclude<Env, Tracer.ParentSpan>>
} = function() {
const dataFirst = typeof arguments[0] !== "string"
const name = dataFirst ? arguments[1] : arguments[0]
const options = tracer.addSpanStackTrace(dataFirst ? arguments[2] : arguments[1])
if (dataFirst) {
const self = arguments[0]
return unwrapScoped(
Effect.flatMap(
Effect.context(),
(context) =>
Effect.map(
Effect.makeSpanScoped(name, options),
(span) => core.provideContext(self, Context.add(context, tracer.spanTag, span))
)
)
)
}
return (self: Effect.Effect<any, any, any>) =>
unwrapScoped(
Effect.flatMap(
Effect.context(),
(context) =>
Effect.map(
Effect.makeSpanScoped(name, options),
(span) => core.provideContext(self, Context.add(context, tracer.spanTag, span))
)
)
)
) as any)
} as any

/** @internal */
export const writeAll = <OutElem>(
Expand Down
Loading

0 comments on commit bf24c50

Please sign in to comment.