diff --git a/agent/context/context.go b/agent/context/context.go index eda88171..e413f6a9 100644 --- a/agent/context/context.go +++ b/agent/context/context.go @@ -2,12 +2,16 @@ package context import ( "context" + "fmt" "time" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/plan" "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/trace" + traceTypes "github.com/yaoapp/yao/trace/types" ) // New create a new context @@ -62,8 +66,19 @@ func WithTimeout(parent Context, timeout time.Duration) (Context, context.Cancel return parent, cancel } -// Release the context and clean up all resources including stacks +// Release the context and clean up all resources including stacks and trace func (ctx *Context) Release() { + // Complete and release trace if exists + if ctx.trace != nil && ctx.Stack != nil && ctx.Stack.TraceID != "" { + // Mark trace as complete (sends final event) + _ = ctx.trace.MarkComplete() + + // Release from global registry (removes from registry and closes resources) + _ = trace.Release(ctx.Stack.TraceID) + + ctx.trace = nil + } + // Clear space if ctx.Space != nil { ctx.Space.Clear() @@ -98,6 +113,70 @@ func (ctx *Context) Send(data []byte) error { return err } +// Trace returns the trace manager for this context, lazily initialized on first call +// Uses the TraceID from ctx.Stack if available, or generates a new one +func (ctx *Context) Trace() (traceTypes.Manager, error) { + // Return trace if already initialized + if ctx.trace != nil { + return ctx.trace, nil + } + + // Get TraceID from Stack or generate new one + var traceID string + if ctx.Stack != nil && ctx.Stack.TraceID != "" { + traceID = ctx.Stack.TraceID + + // Try to load existing trace first + manager, err := trace.Load(traceID) + if err == nil { + // Found in registry, reuse it + ctx.trace = manager + return manager, nil + } + } + + // Get trace configuration from global config + cfg := config.Conf + + // Prepare driver options + var driverOptions []any + var driverType string + + switch cfg.Trace.Driver { + case "store": + driverType = trace.Store + if cfg.Trace.Store == "" { + return nil, fmt.Errorf("trace store ID not configured") + } + driverOptions = []any{cfg.Trace.Store, cfg.Trace.Prefix} + + case "local", "": + driverType = trace.Local + driverOptions = []any{cfg.Trace.Path} + + default: + return nil, fmt.Errorf("unsupported trace driver: %s", cfg.Trace.Driver) + } + + // Create trace using trace.New (handles registry) + createdTraceID, manager, err := trace.New(ctx.Context, driverType, &traceTypes.TraceOption{ + ID: traceID, // Use existing ID from Stack or empty to generate new one + }, driverOptions...) + if err != nil { + return nil, fmt.Errorf("failed to create trace: %w", err) + } + + // Update Stack with the created TraceID if needed + if ctx.Stack != nil && ctx.Stack.TraceID == "" { + ctx.Stack.TraceID = createdTraceID + } + + // Store for future calls + ctx.trace = manager + + return manager, nil +} + // Map the context to a map func (ctx *Context) Map() map[string]interface{} { data := map[string]interface{}{} diff --git a/agent/context/jsapi.go b/agent/context/jsapi.go index e6ffb20f..fcd4ec53 100644 --- a/agent/context/jsapi.go +++ b/agent/context/jsapi.go @@ -1,16 +1,11 @@ package context import ( - "sync" - - "github.com/google/uuid" "github.com/yaoapp/gou/runtime/v8/bridge" + traceJsapi "github.com/yaoapp/yao/trace/jsapi" "rogchap.com/v8go" ) -var objectsMutex = sync.Mutex{} -var objects = map[string]*Context{} - // JsValue return the JavaScript value of the context func (ctx *Context) JsValue(v8ctx *v8go.Context) (*v8go.Value, error) { return ctx.NewObject(v8ctx) @@ -21,10 +16,16 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) { jsObject := v8go.NewObjectTemplate(v8ctx.Isolate()) - // Set the id and release function - id := ctx.objectRegister() - jsObject.Set("__id", id) - jsObject.Set("__release", ctx.objectRelease(v8ctx.Isolate(), id)) + // Set internal field count to 1 to store the goValueID + // Internal fields are not accessible from JavaScript, providing better security + jsObject.SetInternalFieldCount(1) + + // Register context in global bridge registry for efficient Go object retrieval + // The goValueID will be stored in internal field (index 0) after instance creation + goValueID := bridge.RegisterGoObject(ctx) + + // Set release function that will be called when JavaScript object is released + jsObject.Set("__release", ctx.objectRelease(v8ctx.Isolate(), goValueID)) // Set primitive fields in template jsObject.Set("chat_id", ctx.ChatID) @@ -42,16 +43,28 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) { jsObject.Set("accept", string(ctx.Accept)) jsObject.Set("route", ctx.Route) + // Set methods + jsObject.Set("Trace", ctx.traceMethod(v8ctx.Isolate())) + // Create instance instance, err := jsObject.NewInstance(v8ctx) if err != nil { - ctx.objectRelease(v8ctx.Isolate(), id) + // Clean up: release from global registry if instance creation failed + bridge.ReleaseGoObject(goValueID) return nil, err } + // Store the goValueID in internal field (index 0) + // This is not accessible from JavaScript, providing better security obj, err := instance.Value.AsObject() if err != nil { - ctx.objectRelease(v8ctx.Isolate(), id) + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + err = obj.SetInternalField(0, goValueID) + if err != nil { + bridge.ReleaseGoObject(goValueID) return nil, err } @@ -98,19 +111,49 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) { return instance.Value, nil } -func (ctx *Context) objectRegister() string { - id := uuid.NewString() - objectsMutex.Lock() - defer objectsMutex.Unlock() - objects[id] = ctx - return id -} - -func (ctx *Context) objectRelease(iso *v8go.Isolate, id string) *v8go.FunctionTemplate { +// objectRelease releases the Go object from the global bridge registry +// It retrieves the goValueID from internal field (index 0) and releases the Go object +func (ctx *Context) objectRelease(iso *v8go.Isolate, goValueID string) *v8go.FunctionTemplate { return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { - objectsMutex.Lock() - defer objectsMutex.Unlock() - delete(objects, id) + // Get the context object (this) + thisObj, err := info.This().AsObject() + if err == nil && thisObj.InternalFieldCount() > 0 { + // Get goValueID from internal field (index 0) + goValueID := thisObj.GetInternalField(0) + if goValueID != nil && goValueID.IsString() { + // Release from global bridge registry + bridge.ReleaseGoObject(goValueID.String()) + } + } + return v8go.Undefined(info.Context().Isolate()) }) } + +func (ctx *Context) traceMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + + // Get trace manager (lazy initialization) + manager, err := ctx.Trace() + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + + // Get trace ID + traceID := "" + if ctx.Stack != nil { + traceID = ctx.Stack.TraceID + } + + // Create JavaScript Trace object directly + // The Trace object will be used within JavaScript and its __release will be called + // when the JavaScript value is released via defer bridge.FreeJsValue(jsRes) + traceObj, err := traceJsapi.NewTraceObject(v8ctx, traceID, manager) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + + return traceObj + }) +} diff --git a/agent/context/jsapi_test.go b/agent/context/jsapi_test.go index 818bbe3b..83b08043 100644 --- a/agent/context/jsapi_test.go +++ b/agent/context/jsapi_test.go @@ -1,6 +1,7 @@ package context import ( + "context" "fmt" "sync" "testing" @@ -35,7 +36,7 @@ func TestJsValue(t *testing.T) { t.Fatalf("Call failed: %v", err) } assert.Equal(t, "ChatID-123456", res) - assert.Equal(t, 0, len(objects)) + // Note: We can't directly check goMaps cleanup as it's in the bridge package } func testContextJsvalueEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { @@ -132,7 +133,7 @@ func TestJsValueConcurrent(t *testing.T) { // Verify all objects are cleaned up after GC // Note: objects should be released when v8 values are garbage collected - assert.Equal(t, 0, len(objects), "All objects should be cleaned up") + // Note: We can't directly check goMaps cleanup as it's in the bridge package } // TestJsValueRegistrationAndCleanup test the object registration and cleanup mechanism @@ -141,11 +142,6 @@ func TestJsValueRegistrationAndCleanup(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - // Clear objects map before test - objectsMutex.Lock() - objects = map[string]*Context{} - objectsMutex.Unlock() - v8.RegisterFunction("testContextRegistration", testContextRegistrationEmbed) // Create multiple contexts and verify registration @@ -168,7 +164,7 @@ func TestJsValueRegistrationAndCleanup(t *testing.T) { } // All objects should be cleaned up after v8.Call completes - assert.Equal(t, 0, len(objects), "All objects should be cleaned up after execution") + // Note: We can't directly check goMaps cleanup as it's in the bridge package } func testContextRegistrationEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { @@ -186,16 +182,6 @@ func testContextRegistrationFunction(info *v8go.FunctionCallbackInfo) *v8go.Valu return bridge.JsException(info.Context(), err) } - // Verify the object has __id field - id, err := ctx.Get("__id") - if err != nil { - return bridge.JsException(info.Context(), err) - } - - if !id.IsString() { - return bridge.JsException(info.Context(), fmt.Errorf("__id should be a string")) - } - // Verify the object has __release function release, err := ctx.Get("__release") if err != nil { @@ -206,14 +192,14 @@ func testContextRegistrationFunction(info *v8go.FunctionCallbackInfo) *v8go.Valu return bridge.JsException(info.Context(), fmt.Errorf("__release should be a function")) } - // Verify the object is registered - objectsMutex.Lock() - idStr := id.String() - _, exists := objects[idStr] - objectsMutex.Unlock() + // Verify the object has internal field (goValueID is stored in internal field, not accessible from JS) + if ctx.InternalFieldCount() == 0 { + return bridge.JsException(info.Context(), fmt.Errorf("object should have internal field")) + } - if !exists { - return bridge.JsException(info.Context(), fmt.Errorf("object %s not registered", idStr)) + goValueID := ctx.GetInternalField(0) + if goValueID == nil || !goValueID.IsString() { + return bridge.JsException(info.Context(), fmt.Errorf("internal field should contain goValueID string")) } val, err := v8go.NewValue(info.Context().Isolate(), true) @@ -348,7 +334,7 @@ func TestJsValueAllFields(t *testing.T) { _, hasSilent := result["silent"] assert.False(t, hasSilent, "silent (deprecated) should not be exported") - assert.Equal(t, 0, len(objects)) + // Note: We can't directly check goMaps cleanup as it's in the bridge package } func testAllFieldsEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { @@ -442,3 +428,68 @@ func testAllFieldsFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { } return jsVal } + +// TestJsValueTrace test the Trace method on Context +func TestJsValueTrace(t *testing.T) { + + test.Prepare(t, config.Conf) + defer test.Clean() + + cxt := &Context{ + ChatID: "test-chat-id", + AssistantID: "test-assistant-id", + Stack: &Stack{ + TraceID: "test-trace-id", + }, + Context: context.Background(), + } + + res, err := v8.Call(v8.CallOptions{}, ` + function test(cxt) { + // Get trace from context + const trace = cxt.Trace() + + // Verify trace object exists + if (!trace) { + throw new Error("Trace() returned null or undefined") + } + + // Verify trace has expected methods + if (typeof trace.Add !== 'function') { + throw new Error("trace.Add is not a function") + } + if (typeof trace.Info !== 'function') { + throw new Error("trace.Info is not a function") + } + + // Actually use the trace - add a node + const node = trace.Add({ type: "test", content: "Test from context" }, { label: "Test Node" }) + + // Log some info + trace.Info("Testing trace from context") + node.Info("Node info message") + + // Complete the node + node.Complete({ result: "success" }) + + // Return verification info + return { + trace_id: trace.id, + node_id: node.id, + success: true + } + }`, cxt) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + result, ok := res.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", res) + } + + // Verify trace was accessible and operations succeeded + assert.Equal(t, "test-trace-id", result["trace_id"], "trace_id should match") + assert.NotEmpty(t, result["node_id"], "node_id should not be empty") + assert.Equal(t, true, result["success"], "operation should succeed") +} diff --git a/agent/context/types.go b/agent/context/types.go index 2a0f6896..de5b9a22 100644 --- a/agent/context/types.go +++ b/agent/context/types.go @@ -6,6 +6,7 @@ import ( "github.com/yaoapp/gou/plan" "github.com/yaoapp/gou/store" "github.com/yaoapp/yao/openapi/oauth/types" + traceTypes "github.com/yaoapp/yao/trace/types" ) // Accept the accept of the request, it will be used to identify the accept of the request. @@ -120,11 +121,12 @@ type Context struct { // Context context.Context - Space plan.Space `json:"-"` // Shared data space, it will be used to share data between the request and the call - Cache store.Store `json:"-"` // Cache store, it will be used to store the message cache, default is "__yao.agent.cache" - Stack *Stack `json:"-"` // Stack, current active stack of the request - Stacks map[string]*Stack `json:"-"` // Stacks, all stacks in this request (for trace logging) - Writer Writer `json:"-"` // Writer, it will be used to write response data to the client + Space plan.Space `json:"-"` // Shared data space, it will be used to share data between the request and the call + Cache store.Store `json:"-"` // Cache store, it will be used to store the message cache, default is "__yao.agent.cache" + Stack *Stack `json:"-"` // Stack, current active stack of the request + Stacks map[string]*Stack `json:"-"` // Stacks, all stacks in this request (for trace logging) + Writer Writer `json:"-"` // Writer, it will be used to write response data to the client + trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access // Authorized information Authorized *types.AuthorizedInfo `json:"authorized,omitempty"` // Authorized information diff --git a/config/config.go b/config/config.go index 4998cf9b..26effa96 100644 --- a/config/config.go +++ b/config/config.go @@ -102,9 +102,50 @@ func Load() Config { } } + // Trace Driver - default based on mode + if cfg.Trace.Driver == "" { + if cfg.Mode == "development" { + cfg.Trace.Driver = "local" + } else { + cfg.Trace.Driver = "store" + } + } + + // Trace Path - default to same directory as log file when using local driver + if cfg.Trace.Driver == "local" { + if cfg.Trace.Path == "" { + // Use the log file directory + logDir := cfg.GetLogDir() + cfg.Trace.Path = filepath.Join(logDir, "traces") + } + + if !filepath.IsAbs(cfg.Trace.Path) { + cfg.Trace.Path = filepath.Join(cfg.Root, cfg.Trace.Path) + } + } + + // Trace Prefix - default prefix for store driver + if cfg.Trace.Driver == "store" && cfg.Trace.Prefix == "" { + cfg.Trace.Prefix = "trace:" + } + return cfg } +// GetLogDir returns the directory of the log file +func (cfg *Config) GetLogDir() string { + logPath := cfg.Log + if logPath == "" { + logPath = filepath.Join(cfg.Root, "logs", "application.log") + } + + if !filepath.IsAbs(logPath) { + logPath = filepath.Join(cfg.Root, logPath) + } + + return filepath.Dir(logPath) +} + // Production 设定为生产环境 func Production() { os.Setenv("YAO_MODE", "production") diff --git a/config/types.go b/config/types.go index 6a57a152..510b208f 100644 --- a/config/types.go +++ b/config/types.go @@ -25,6 +25,7 @@ type Config struct { Session Session `json:"session,omitempty"` // Session Config Studio Studio `json:"studio,omitempty"` // Studio config Runtime Runtime `json:"runtime,omitempty"` // Runtime config + Trace Trace `json:"trace,omitempty"` // Trace config } // Studio the studio config @@ -67,3 +68,11 @@ type Runtime struct { Precompile bool `json:"precompile,omitempty" env:"YAO_RUNTIME_PRECOMPILE" envDefault:"false"` // if true compile scripts when the VM is created. this will increase the load time, but the script will run faster. the default value is false Import bool `json:"import,omitempty" env:"YAO_RUNTIME_IMPORT" envDefault:"true"` // If false the import statement will be disabled, the default value is true. } + +// Trace config +type Trace struct { + Driver string `json:"driver,omitempty" env:"YAO_TRACE_DRIVER"` // The trace driver. local (development) | store (production) + Path string `json:"path,omitempty" env:"YAO_TRACE_PATH"` // The local file path for trace storage + Store string `json:"store,omitempty" env:"YAO_TRACE_STORE"` // The store ID when driver is "store" + Prefix string `json:"prefix,omitempty" env:"YAO_TRACE_PREFIX"` // The prefix for trace storage keys +} diff --git a/main.go b/main.go index e5960a48..356fb79e 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( _ "github.com/yaoapp/yao/helper" _ "github.com/yaoapp/yao/openai" _ "github.com/yaoapp/yao/seed" + _ "github.com/yaoapp/yao/trace/jsapi" _ "github.com/yaoapp/yao/wework" "github.com/yaoapp/yao/cmd" diff --git a/trace/jsapi/jsapi.go b/trace/jsapi/jsapi.go new file mode 100644 index 00000000..71e4c527 --- /dev/null +++ b/trace/jsapi/jsapi.go @@ -0,0 +1,83 @@ +package jsapi + +import ( + "fmt" + + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/gou/runtime/v8/bridge" + "rogchap.com/v8go" +) + +func init() { + // Auto-register Trace JavaScript API when package is imported + v8.RegisterFunction("Trace", ExportFunction) +} + +// Usage from JavaScript: +// +// const trace = new Trace({ driver: "local", path: "/tmp/traces" }) +// const node = trace.Add({ type: "step", content: "Processing..." }, { label: "Step 1" }) +// node.Complete({ result: "Done" }) +// +// Objects: +// - Trace: Main trace manager (constructor) +// - Node: Individual trace node +// - Space: Memory space for key-value storage + +// ExportFunction exports the Trace constructor function template +// This is used by v8.RegisterFunction +func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, traceConstructor) +} + +// traceConstructor is the JavaScript constructor for Trace +// Usage: new Trace(options) +func traceConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + // Parse options + options := make(map[string]interface{}) + if len(args) > 0 && !args[0].IsNullOrUndefined() { + optionsJS, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, fmt.Sprintf("invalid options: %s", err)) + } + if optionsMap, ok := optionsJS.(map[string]interface{}); ok { + options = optionsMap + } + } + + // Create trace object + traceObj, err := TraceNew(ctx, options) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return traceObj +} + +// ExportLoadFunction exports the Trace.Load static method +// This can be used to load an existing trace by ID +func ExportLoadFunction(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, traceLoadFunction) +} + +// traceLoadFunction is the JavaScript function for Trace.Load +// Usage: Trace.Load(traceID) +func traceLoadFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Load requires a trace ID") + } + + traceID := args[0].String() + traceObj, err := TraceLoad(ctx, traceID) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return traceObj +} diff --git a/trace/jsapi/jsapi_test.go b/trace/jsapi/jsapi_test.go new file mode 100644 index 00000000..0b72209c --- /dev/null +++ b/trace/jsapi/jsapi_test.go @@ -0,0 +1,544 @@ +package jsapi + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" + "github.com/yaoapp/yao/trace/types" + "rogchap.com/v8go" +) + +// TestTraceNew test creating a new trace from JavaScript +func TestTraceNew(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + v8.RegisterFunction("testTraceNew", testTraceNewEmbed) + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + return testTraceNew(trace) + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With Internal Field + goMaps, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + + // After v8.Call returns, the jsRes should have been released via defer bridge.FreeJsValue(jsRes) + // This should have triggered __release() on the trace object, cleaning up the goMaps + // Note: We can't directly check goMaps as it's in the bridge package +} + +func testTraceNewEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, testTraceNewFunction) +} + +func testTraceNewFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Missing parameters") + } + + trace, err := args[0].AsObject() + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Verify the trace has id field + traceID, err := trace.Get("id") + if err != nil { + return bridge.JsException(info.Context(), err) + } + + if !traceID.IsString() { + return bridge.JsException(info.Context(), fmt.Errorf("id should be a string")) + } + + // Verify the trace has __release function + release, err := trace.Get("__release") + if err != nil { + return bridge.JsException(info.Context(), err) + } + + if !release.IsFunction() { + return bridge.JsException(info.Context(), fmt.Errorf("__release should be a function")) + } + + // Return the trace object itself so its __release will be called when v8.Call finishes + return args[0] +} + +// TestTraceAddNode test adding nodes to trace +func TestTraceAddNode(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + v8.RegisterFunction("testTraceAddNode", testTraceAddNodeEmbed) + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + const node = trace.Add({ type: "step", content: "Test step" }, { label: "Step 1" }) + // testTraceAddNode returns the trace object itself + return testTraceAddNode(trace, node) + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With Internal Field + goMaps, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +func testTraceAddNodeEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, testTraceAddNodeFunction) +} + +func testTraceAddNodeFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "Missing parameters") + } + + trace, err := args[0].AsObject() + if err != nil { + return bridge.JsException(info.Context(), err) + } + + node, err := args[1].AsObject() + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Get trace ID + traceID, err := trace.Get("id") + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Get node ID + nodeID, err := node.Get("id") + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Verify IDs exist + if traceID.IsUndefined() || nodeID.IsUndefined() { + return bridge.JsException(info.Context(), fmt.Errorf("missing trace or node ID")) + } + + // Return the trace object itself to trigger __release + return args[0] +} + +// TestTraceNodeComplete test completing a node +func TestTraceNodeComplete(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + const node = trace.Add({ type: "step", content: "Test step" }, { label: "Step 1" }) + + // Log some messages + node.Info("Processing...") + node.Debug("Debug info") + + // Complete the node + node.Complete({ result: "success" }) + + return trace + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With Internal Field + goMaps, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +// TestTraceSpace test creating and using spaces +func TestTraceSpace(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + v8.RegisterFunction("testTraceSpace", testTraceSpaceEmbed) + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + const space = trace.CreateSpace({ label: "Test Space" }) + + // Set some values + space.Set("key1", "value1") + space.Set("key2", 123) + space.Set("key3", { nested: "object" }) + + // Call test function for verification + testTraceSpace(space) + + // Return trace object to trigger __release + return trace + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With Internal Field + goMaps, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +func testTraceSpaceEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, testTraceSpaceFunction) +} + +func testTraceSpaceFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Missing parameters") + } + + space, err := args[0].AsObject() + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Get space ID + spaceID, err := space.Get("id") + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Get stored values to verify + getVal := func(obj *v8go.Object, method string, key string) (interface{}, error) { + getMethod, err := obj.Get(method) + if err != nil { + return nil, err + } + defer getMethod.Release() + getFn, err := getMethod.AsFunction() + if err != nil { + return nil, err + } + keyVal, err := v8go.NewValue(info.Context().Isolate(), key) + if err != nil { + return nil, err + } + defer keyVal.Release() + result, err := getFn.Call(obj.Value, keyVal) + if err != nil { + return nil, err + } + return bridge.GoValue(result, info.Context()) + } + + key1, _ := getVal(space, "Get", "key1") + key2, _ := getVal(space, "Get", "key2") + + // Verify values exist + if spaceID.IsUndefined() || key1 == nil || key2 == nil { + return bridge.JsException(info.Context(), fmt.Errorf("missing space data")) + } + + // This function is just for verification, we don't need to return a new object + // Return undefined, the outer JavaScript will return the trace object + return v8go.Undefined(info.Context().Isolate()) +} + +// TestTraceConcurrent test concurrent trace operations +func TestTraceConcurrent(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + // Number of concurrent goroutines + concurrency := 10 + iterationsPerGoroutine := 5 + + var wg sync.WaitGroup + errors := make(chan error, concurrency*iterationsPerGoroutine) + results := make(chan string, concurrency*iterationsPerGoroutine) + + // Launch concurrent goroutines + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func(routineID int) { + defer wg.Done() + + for j := 0; j < iterationsPerGoroutine; j++ { + script := fmt.Sprintf(` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces-%d-%d" }) + const node = trace.Add({ type: "step", content: "Test %d-%d" }, { label: "Step" }) + node.Info("Processing") + node.Complete({ result: "done" }) + return trace + }`, routineID, j, routineID, j) + + res, err := v8.Call(v8.CallOptions{}, script) + if err != nil { + errors <- fmt.Errorf("routine %d iteration %d failed: %v", routineID, j, err) + return + } + + // With Internal Field + goMaps, res should now be the manager directly + if _, ok := res.(types.Manager); ok { + // Successfully got manager, add a result + results <- fmt.Sprintf("routine-%d-iteration-%d", routineID, j) + } else { + errors <- fmt.Errorf("routine %d iteration %d: expected types.Manager, got %T", routineID, j, res) + } + } + }(i) + } + + // Wait for all goroutines to complete + wg.Wait() + close(errors) + close(results) + + // Check for errors + for err := range errors { + t.Error(err) + } + + // Verify all results + resultCount := 0 + for range results { + resultCount++ + } + + // Verify the correct number of results + expectedResults := concurrency * iterationsPerGoroutine + assert.Equal(t, expectedResults, resultCount, "Should have %d results", expectedResults) + + // Verify all traces are cleaned up + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +// TestTraceParallel test parallel node execution +func TestTraceParallel(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + v8.RegisterFunction("testTraceParallel", testTraceParallelEmbed) + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + + // Must call Add() first to create root node + trace.Add({ type: "root", content: "Root" }, { label: "Root" }) + + // Create parallel nodes + const nodes = trace.Parallel([ + { input: { type: "task", content: "Task 1" }, option: { label: "Parallel 1" } }, + { input: { type: "task", content: "Task 2" }, option: { label: "Parallel 2" } }, + { input: { type: "task", content: "Task 3" }, option: { label: "Parallel 3" } } + ]) + + // Call test function for verification + testTraceParallel(nodes) + + // Return trace object to trigger __release + return trace + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With Internal Field + goMaps, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +func testTraceParallelEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, testTraceParallelFunction) +} + +func testTraceParallelFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Missing parameters") + } + + nodes, err := args[0].AsObject() + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Get array length + lengthVal, err := nodes.Get("length") + if err != nil { + return bridge.JsException(info.Context(), err) + } + + // Verify we got 3 nodes + if lengthVal.Int32() != 3 { + return bridge.JsException(info.Context(), fmt.Errorf("expected 3 nodes, got %d", lengthVal.Int32())) + } + + // Return undefined, the outer JavaScript will return the trace object + return v8go.Undefined(info.Context().Isolate()) +} + +// TestTraceMarkComplete test marking trace as complete +func TestTraceMarkComplete(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + const node = trace.Add({ type: "step", content: "Test step" }, { label: "Step 1" }) + node.Complete({ result: "success" }) + + // Mark entire trace as complete + trace.MarkComplete() + + // Check if complete + const isComplete = trace.IsComplete() + + return trace + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With Internal Field + goMaps, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +// TestTracePassAsParameter test passing trace object as parameter to a function +func TestTracePassAsParameter(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + v8.RegisterFunction("processTrace", processTraceEmbed) + res, err := v8.Call(v8.CallOptions{}, ` + function test() { + // Define a function that accepts trace as parameter + const handleTrace = function(trace) { + // Add a node using the passed trace + const node = trace.Add({ type: "step", content: "Step from handler" }, { label: "Handler Step" }) + node.Info("Processing in handler function") + node.Complete({ result: "handler completed" }) + + // Return some info + return { + traceId: trace.id, + nodeId: node.id + } + } + + // Create trace and pass it to the handler + const trace = new Trace({ driver: "local", path: "/tmp/test-traces" }) + const result = handleTrace(trace) + + // Also test passing to a Go function + processTrace(trace) + + // Return trace to trigger __release + return trace + }`) + if err != nil { + t.Fatalf("Call failed: %v", err) + } + + // With __govalue function, res should now be the manager directly + manager, ok := res.(types.Manager) + if !ok { + t.Fatalf("Expected types.Manager, got %T", res) + } + + assert.NotNil(t, manager, "manager should not be nil") + // Note: We can't directly check goMaps cleanup as it's in the bridge package +} + +func processTraceEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, processTraceFunction) +} + +func processTraceFunction(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Missing trace parameter") + } + + // Try to get the trace object + traceValue := args[0] + + // Convert to Go value - with __govalue function, this should return the manager directly + goValue, err := bridge.GoValue(traceValue, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), fmt.Errorf("failed to convert trace: %v", err)) + } + + fmt.Printf("\n=== Go function received trace ===\n") + fmt.Printf("Type: %T\n", goValue) + + // Check if we got the manager directly (new __govalue behavior) + if manager, ok := goValue.(types.Manager); ok { + fmt.Printf("✅ SUCCESS: Got manager directly via __govalue function!\n") + fmt.Printf(" Manager type: %T\n", manager) + + // Now we can use the manager directly! + manager.Info("Message from Go function via __govalue") + return v8go.Undefined(info.Context().Isolate()) + } + + // Fallback: if we got a map (shouldn't happen with __govalue) + if traceMap, ok := goValue.(map[string]interface{}); ok { + fmt.Printf("⚠️ Got map (fallback): %v\n", getMapKeys(traceMap)) + return bridge.JsException(info.Context(), fmt.Errorf("unexpected: got map instead of manager")) + } + + return bridge.JsException(info.Context(), fmt.Errorf("unexpected type: %T", goValue)) +} + +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/trace/jsapi/node.go b/trace/jsapi/node.go new file mode 100644 index 00000000..f6a03ce4 --- /dev/null +++ b/trace/jsapi/node.go @@ -0,0 +1,308 @@ +package jsapi + +import ( + "fmt" + + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/yao/trace/types" + "rogchap.com/v8go" +) + +// Helper functions + +func parseTraceInput(obj *v8go.Object, ctx *v8go.Context) (types.TraceInput, error) { + goVal, err := bridge.GoValue(obj.Value, ctx) + if err != nil { + return nil, err + } + return goVal, nil +} + +func parseTraceNodeOption(obj *v8go.Object) types.TraceNodeOption { + option := types.TraceNodeOption{} + if labelVal, err := obj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() { + option.Label = labelVal.String() + } + if iconVal, err := obj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() { + option.Icon = iconVal.String() + } + if descVal, err := obj.Get("description"); err == nil && !descVal.IsNullOrUndefined() { + option.Description = descVal.String() + } + return option +} + +// NewNodeObject creates a JavaScript Node object (pure JS object, no Go mapping) +func NewNodeObject(v8ctx *v8go.Context, node types.Node) (*v8go.Value, error) { + jsObject := v8go.NewObjectTemplate(v8ctx.Isolate()) + + // Set primitive fields + jsObject.Set("id", node.ID()) + + // Set methods + jsObject.Set("Info", nodeInfoMethod(v8ctx.Isolate(), node)) + jsObject.Set("Debug", nodeDebugMethod(v8ctx.Isolate(), node)) + jsObject.Set("Error", nodeErrorMethod(v8ctx.Isolate(), node)) + jsObject.Set("Warn", nodeWarnMethod(v8ctx.Isolate(), node)) + jsObject.Set("Add", nodeAddMethod(v8ctx.Isolate(), node)) + jsObject.Set("Parallel", nodeParallelMethod(v8ctx.Isolate(), node)) + jsObject.Set("SetOutput", nodeSetOutputMethod(v8ctx.Isolate(), node)) + jsObject.Set("SetMetadata", nodeSetMetadataMethod(v8ctx.Isolate(), node)) + jsObject.Set("Complete", nodeCompleteMethod(v8ctx.Isolate(), node)) + jsObject.Set("Fail", nodeFailMethod(v8ctx.Isolate(), node)) + + // Create instance + instance, err := jsObject.NewInstance(v8ctx) + if err != nil { + return nil, err + } + + return instance.Value, nil +} + +// Node method templates + +func nodeInfoMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + node.Info(args[0].String()) + } + return info.This().Value + }) +} + +func nodeDebugMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + node.Debug(args[0].String()) + } + return info.This().Value + }) +} + +func nodeErrorMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + node.Error(args[0].String()) + } + return info.This().Value + }) +} + +func nodeWarnMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + node.Warn(args[0].String()) + } + return info.This().Value + }) +} + +func nodeAddMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 2 { + return bridge.JsException(ctx, "Add requires 2 arguments: (input, option)") + } + + // Parse input + inputObj, err := args[0].AsObject() + if err != nil { + return bridge.JsException(ctx, "first argument must be an object") + } + input, _ := parseTraceInput(inputObj, ctx) + + // Parse option + optionObj, err := args[1].AsObject() + if err != nil { + return bridge.JsException(ctx, "second argument must be an object") + } + option := parseTraceNodeOption(optionObj) + + // Call node.Add + childNode, err := node.Add(input, option) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + // Create child node JS object + childNodeObj, err := NewNodeObject(ctx, childNode) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return childNodeObj + }) +} + +func nodeParallelMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Parallel requires an array argument") + } + + // Parse array + arrayObj, err := args[0].AsObject() + if err != nil { + return bridge.JsException(ctx, "argument must be an array") + } + + lengthVal, err := arrayObj.Get("length") + if err != nil { + return bridge.JsException(ctx, "invalid array") + } + + length := int(lengthVal.Int32()) + parallelInputs := make([]types.TraceParallelInput, 0, length) + + for i := 0; i < length; i++ { + itemVal, err := arrayObj.GetIdx(uint32(i)) + if err != nil { + continue + } + + itemObj, err := itemVal.AsObject() + if err != nil { + continue + } + + // Parse input + var input types.TraceInput + if inputVal, err := itemObj.Get("input"); err == nil && inputVal.IsObject() { + inputObjInner, _ := inputVal.AsObject() + input, _ = parseTraceInput(inputObjInner, ctx) + } + + // Parse option + var option types.TraceNodeOption + if optionVal, err := itemObj.Get("option"); err == nil && optionVal.IsObject() { + optionObjInner, _ := optionVal.AsObject() + option = parseTraceNodeOption(optionObjInner) + } + + parallelInputs = append(parallelInputs, types.TraceParallelInput{ + Input: input, + Option: option, + }) + } + + // Call node.Parallel + childNodes, err := node.Parallel(parallelInputs) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + // Create array of node objects + result := make([]interface{}, len(childNodes)) + for i, childNode := range childNodes { + childNodeObj, err := NewNodeObject(ctx, childNode) + if err != nil { + continue + } + result[i] = childNodeObj + } + + jsVal, err := bridge.JsValue(ctx, result) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return jsVal + }) +} + +func nodeSetOutputMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "SetOutput requires an output argument") + } + + output, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + if err := node.SetOutput(output); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func nodeSetMetadataMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 2 { + return bridge.JsException(ctx, "SetMetadata requires 2 arguments: (key, value)") + } + + key := args[0].String() + value, err := bridge.GoValue(args[1], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + if err := node.SetMetadata(key, value); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func nodeCompleteMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + // Optional output parameter + if len(args) > 0 { + output, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + if err := node.Complete(output); err != nil { + return bridge.JsException(ctx, err.Error()) + } + } else { + if err := node.Complete(); err != nil { + return bridge.JsException(ctx, err.Error()) + } + } + + return info.This().Value + }) +} + +func nodeFailMethod(iso *v8go.Isolate, node types.Node) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Fail requires an error message") + } + + errMsg := args[0].String() + if err := node.Fail(fmt.Errorf("%s", errMsg)); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} diff --git a/trace/jsapi/space.go b/trace/jsapi/space.go new file mode 100644 index 00000000..ada6911c --- /dev/null +++ b/trace/jsapi/space.go @@ -0,0 +1,142 @@ +package jsapi + +import ( + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/yao/trace/types" + "rogchap.com/v8go" +) + +// NewSpaceObject creates a JavaScript Space object (pure JS object, no Go mapping) +// Since TraceSpace is a struct and not an interface, we use manager methods to operate on it +func NewSpaceObject(v8ctx *v8go.Context, manager types.Manager, space *types.TraceSpace) (*v8go.Value, error) { + jsObject := v8go.NewObjectTemplate(v8ctx.Isolate()) + + // Set primitive fields + jsObject.Set("id", space.ID) + + // Set methods - they operate through manager + jsObject.Set("Set", spaceSetMethod(v8ctx.Isolate(), manager, space.ID)) + jsObject.Set("Get", spaceGetMethod(v8ctx.Isolate(), manager, space.ID)) + jsObject.Set("Has", spaceHasMethod(v8ctx.Isolate(), manager, space.ID)) + jsObject.Set("Delete", spaceDeleteMethod(v8ctx.Isolate(), manager, space.ID)) + jsObject.Set("Clear", spaceClearMethod(v8ctx.Isolate(), manager, space.ID)) + jsObject.Set("Keys", spaceKeysMethod(v8ctx.Isolate(), manager, space.ID)) + + // Create instance + instance, err := jsObject.NewInstance(v8ctx) + if err != nil { + return nil, err + } + + return instance.Value, nil +} + +// Space method templates + +func spaceSetMethod(iso *v8go.Isolate, manager types.Manager, spaceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 2 { + return bridge.JsException(ctx, "Set requires 2 arguments: (key, value)") + } + + key := args[0].String() + value, err := bridge.GoValue(args[1], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + if err := manager.SetSpaceValue(spaceID, key, value); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func spaceGetMethod(iso *v8go.Isolate, manager types.Manager, spaceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Get requires a key argument") + } + + key := args[0].String() + value, err := manager.GetSpaceValue(spaceID, key) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + jsVal, err := bridge.JsValue(ctx, value) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return jsVal + }) +} + +func spaceHasMethod(iso *v8go.Isolate, manager types.Manager, spaceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Has requires a key argument") + } + + key := args[0].String() + has := manager.HasSpaceValue(spaceID, key) + + jsVal, _ := v8go.NewValue(iso, has) + return jsVal + }) +} + +func spaceDeleteMethod(iso *v8go.Isolate, manager types.Manager, spaceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Delete requires a key argument") + } + + key := args[0].String() + if err := manager.DeleteSpaceValue(spaceID, key); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func spaceClearMethod(iso *v8go.Isolate, manager types.Manager, spaceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + + if err := manager.ClearSpaceValues(spaceID); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func spaceKeysMethod(iso *v8go.Isolate, manager types.Manager, spaceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + + keys := manager.ListSpaceKeys(spaceID) + jsVal, err := bridge.JsValue(ctx, keys) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return jsVal + }) +} diff --git a/trace/jsapi/trace.go b/trace/jsapi/trace.go new file mode 100644 index 00000000..9838746f --- /dev/null +++ b/trace/jsapi/trace.go @@ -0,0 +1,506 @@ +package jsapi + +import ( + "context" + "fmt" + + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/trace" + "github.com/yaoapp/yao/trace/types" + "rogchap.com/v8go" +) + +func init() { + // Auto-register Trace JavaScript API when package is imported + v8.RegisterFunction("Trace", ExportFunction) +} + +// NewTraceObject creates a JavaScript Trace object +func NewTraceObject(v8ctx *v8go.Context, traceID string, manager types.Manager) (*v8go.Value, error) { + jsObject := v8go.NewObjectTemplate(v8ctx.Isolate()) + + // Set internal field count to 1 to store the __go_id + // Internal fields are not accessible from JavaScript, providing better security + jsObject.SetInternalFieldCount(1) + + // Register manager in global bridge registry for efficient Go object retrieval + // The goValueID will be stored in internal field (index 0) after instance creation + // Internal fields are not accessible from JavaScript, providing better security + goValueID := bridge.RegisterGoObject(manager) + + // Set primitive fields + jsObject.Set("id", traceID) + + // Set release function that will be called when JavaScript object is released + // This function retrieves goValueID from internal field and releases the Go object + jsObject.Set("__release", traceGoRelease(v8ctx.Isolate(), traceID)) + + // Set methods + jsObject.Set("Add", traceAddMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Parallel", traceParallelMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Info", traceInfoMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Debug", traceDebugMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Error", traceErrorMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Warn", traceWarnMethod(v8ctx.Isolate(), manager)) + jsObject.Set("SetOutput", traceSetOutputMethod(v8ctx.Isolate(), manager)) + jsObject.Set("SetMetadata", traceSetMetadataMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Complete", traceCompleteMethod(v8ctx.Isolate(), manager)) + jsObject.Set("Fail", traceFailMethod(v8ctx.Isolate(), manager)) + jsObject.Set("MarkComplete", traceMarkCompleteMethod(v8ctx.Isolate(), manager)) + jsObject.Set("CreateSpace", traceCreateSpaceMethod(v8ctx.Isolate(), manager)) + jsObject.Set("GetSpace", traceGetSpaceMethod(v8ctx.Isolate(), manager)) + jsObject.Set("IsComplete", traceIsCompleteMethod(v8ctx.Isolate(), manager)) + + // Create instance + instance, err := jsObject.NewInstance(v8ctx) + if err != nil { + // Clean up: release from global registry if instance creation failed + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + // Store the goValueID in internal field (index 0) + // This is not accessible from JavaScript, providing better security + obj, err := instance.Value.AsObject() + if err != nil { + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + err = obj.SetInternalField(0, goValueID) + if err != nil { + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + return instance.Value, nil +} + +// TraceNew creates a new Trace instance from JavaScript +// Usage: new Trace(options) +func TraceNew(v8ctx *v8go.Context, options map[string]interface{}) (*v8go.Value, error) { + cfg := config.Conf + + // Prepare driver options + var driverOptions []any + var driverType string + + // Allow override from options + driver, _ := options["driver"].(string) + if driver == "" { + driver = cfg.Trace.Driver + } + + switch driver { + case "store": + driverType = trace.Store + storeID := cfg.Trace.Store + if sid, ok := options["store"].(string); ok && sid != "" { + storeID = sid + } + prefix := cfg.Trace.Prefix + if pfx, ok := options["prefix"].(string); ok { + prefix = pfx + } + driverOptions = []any{storeID, prefix} + + case "local", "": + driverType = trace.Local + path := cfg.Trace.Path + if p, ok := options["path"].(string); ok && p != "" { + path = p + } + driverOptions = []any{path} + + default: + return nil, fmt.Errorf("unsupported trace driver: %s", driver) + } + + // Parse trace options + var traceOpt types.TraceOption + if id, ok := options["id"].(string); ok { + traceOpt.ID = id + } + + // Create trace + goCtx := context.Background() + traceID, manager, err := trace.New(goCtx, driverType, &traceOpt, driverOptions...) + if err != nil { + return nil, err + } + + return NewTraceObject(v8ctx, traceID, manager) +} + +// TraceLoad loads an existing trace from JavaScript +// Usage: Trace.Load(traceID) +func TraceLoad(v8ctx *v8go.Context, traceID string) (*v8go.Value, error) { + manager, err := trace.Load(traceID) + if err != nil { + return nil, err + } + + return NewTraceObject(v8ctx, traceID, manager) +} + +// traceGoRelease releases the Go object from the global bridge registry +// It retrieves the goValueID from internal field (index 0) and releases the Go object +// It also calls trace.Release to cleanup the trace globally +func traceGoRelease(iso *v8go.Isolate, traceID string) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + // Get the trace object (this) + thisObj, err := info.This().AsObject() + if err == nil && thisObj.InternalFieldCount() > 0 { + // Get goValueID from internal field (index 0) + goValueIDValue := thisObj.GetInternalField(0) + if goValueIDValue != nil && goValueIDValue.IsString() { + goValueID := goValueIDValue.String() + // Release from global bridge registry + bridge.ReleaseGoObject(goValueID) + } + } + + // Call global trace.Release to remove from registry and stop background goroutines + trace.Release(traceID) + + return v8go.Undefined(info.Context().Isolate()) + }) +} + +// Method templates + +func traceAddMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Add requires at least 1 argument: (input, option?)") + } + + // Parse input + input, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, fmt.Sprintf("invalid input: %s", err)) + } + + // Parse option + var option types.TraceNodeOption + if len(args) > 1 && !args[1].IsNullOrUndefined() { + optionObj, err := args[1].AsObject() + if err == nil { + if labelVal, err := optionObj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() { + option.Label = labelVal.String() + } + if iconVal, err := optionObj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() { + option.Icon = iconVal.String() + } + if descVal, err := optionObj.Get("description"); err == nil && !descVal.IsNullOrUndefined() { + option.Description = descVal.String() + } + } + } + + // Call manager.Add + node, err := manager.Add(input, option) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + // Create node JS object + nodeObj, err := NewNodeObject(ctx, node) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return nodeObj + }) +} + +func traceParallelMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Parallel requires an array argument") + } + + // Parse array + parallelInputsJS, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, fmt.Sprintf("invalid parallel inputs: %s", err)) + } + + // Convert to []types.TraceParallelInput + parallelInputsArray, ok := parallelInputsJS.([]interface{}) + if !ok { + return bridge.JsException(ctx, "Parallel argument must be an array") + } + + parallelInputs := make([]types.TraceParallelInput, 0, len(parallelInputsArray)) + for _, item := range parallelInputsArray { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + parallelInput := types.TraceParallelInput{} + if input, ok := itemMap["input"]; ok { + parallelInput.Input = input + } + if option, ok := itemMap["option"].(map[string]interface{}); ok { + if label, ok := option["label"].(string); ok { + parallelInput.Option.Label = label + } + if icon, ok := option["icon"].(string); ok { + parallelInput.Option.Icon = icon + } + if desc, ok := option["description"].(string); ok { + parallelInput.Option.Description = desc + } + } + + parallelInputs = append(parallelInputs, parallelInput) + } + + // Call manager.Parallel + nodes, err := manager.Parallel(parallelInputs) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + // Create array of node objects + result := make([]interface{}, len(nodes)) + for i, node := range nodes { + nodeObj, err := NewNodeObject(ctx, node) + if err != nil { + continue + } + result[i] = nodeObj + } + + jsVal, err := bridge.JsValue(ctx, result) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return jsVal + }) +} + +func traceInfoMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + manager.Info(args[0].String()) + } + return info.This().Value + }) +} + +func traceDebugMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + manager.Debug(args[0].String()) + } + return info.This().Value + }) +} + +func traceErrorMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + manager.Error(args[0].String()) + } + return info.This().Value + }) +} + +func traceWarnMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) > 0 { + manager.Warn(args[0].String()) + } + return info.This().Value + }) +} + +func traceSetOutputMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "SetOutput requires an output argument") + } + + output, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + if err := manager.SetOutput(output); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func traceSetMetadataMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 2 { + return bridge.JsException(ctx, "SetMetadata requires 2 arguments: (key, value)") + } + + key := args[0].String() + value, err := bridge.GoValue(args[1], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + if err := manager.SetMetadata(key, value); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func traceCompleteMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + // Optional output parameter + if len(args) > 0 && !args[0].IsNullOrUndefined() { + output, err := bridge.GoValue(args[0], ctx) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + if err := manager.Complete(output); err != nil { + return bridge.JsException(ctx, err.Error()) + } + } else { + if err := manager.Complete(); err != nil { + return bridge.JsException(ctx, err.Error()) + } + } + + return info.This().Value + }) +} + +func traceFailMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "Fail requires an error message") + } + + errMsg := args[0].String() + if err := manager.Fail(fmt.Errorf("%s", errMsg)); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func traceMarkCompleteMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + + if err := manager.MarkComplete(); err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return info.This().Value + }) +} + +func traceCreateSpaceMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + // Parse option + var option types.TraceSpaceOption + if len(args) > 0 && !args[0].IsNullOrUndefined() { + optionObj, err := args[0].AsObject() + if err == nil { + if labelVal, err := optionObj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() { + option.Label = labelVal.String() + } + if iconVal, err := optionObj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() { + option.Icon = iconVal.String() + } + if descVal, err := optionObj.Get("description"); err == nil && !descVal.IsNullOrUndefined() { + option.Description = descVal.String() + } + } + } + + // Call manager.CreateSpace + space, err := manager.CreateSpace(option) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + // Create space JS object + spaceObj, err := NewSpaceObject(ctx, manager, space) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return spaceObj + }) +} + +func traceGetSpaceMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(ctx, "GetSpace requires a space ID") + } + + spaceID := args[0].String() + space, err := manager.GetSpace(spaceID) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + if space == nil { + return v8go.Null(iso) + } + + // Create space JS object + spaceObj, err := NewSpaceObject(ctx, manager, space) + if err != nil { + return bridge.JsException(ctx, err.Error()) + } + + return spaceObj + }) +} + +func traceIsCompleteMethod(iso *v8go.Isolate, manager types.Manager) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + isComplete := manager.IsComplete() + jsVal, _ := v8go.NewValue(iso, isComplete) + return jsVal + }) +}