Merge pull request #824 from multica-ai/agent/j/642cc7b4

feat(daemon): add token usage log scanning for OpenCode, OpenClaw, Hermes
This commit is contained in:
Bohan Jiang
2026-04-13 13:50:41 +08:00
committed by GitHub
7 changed files with 794 additions and 2 deletions

View File

@@ -0,0 +1,173 @@
package usage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"time"
)
// scanHermes reads Hermes JSONL session files from
// ~/.hermes/sessions/*.jsonl
// and extracts token usage from assistant message and usage_update entries.
//
// Hermes communicates via the ACP (Agent Communication Protocol) and logs
// session events as JSONL. Token usage appears in:
// - "assistant" messages with a "usage" field
// - "usage_update" notification entries with cumulative token snapshots
func (s *Scanner) scanHermes() []Record {
root := hermesSessionRoot()
if root == "" {
return nil
}
// Glob for session files: sessions/*.jsonl
pattern := filepath.Join(root, "*.jsonl")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("hermes glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
record := s.parseHermesFile(f)
if record != nil {
allRecords = append(allRecords, *record)
}
}
return mergeRecords(allRecords)
}
// hermesSessionRoot returns the Hermes sessions directory.
func hermesSessionRoot() string {
if hermesHome := os.Getenv("HERMES_HOME"); hermesHome != "" {
dir := filepath.Join(hermesHome, "sessions")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
// Check common locations.
candidates := []string{
filepath.Join(home, ".hermes", "sessions"),
filepath.Join(home, ".local", "share", "hermes", "sessions"),
filepath.Join(home, ".config", "hermes", "sessions"),
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
return ""
}
// hermesLine represents a line in a Hermes session JSONL file.
// Hermes session logs contain both message events and notification events.
type hermesLine struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"` // RFC3339
Model string `json:"model"`
Usage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
} `json:"usage"`
}
// parseHermesFile extracts the final cumulative token usage from a Hermes session file.
// Hermes usage_update events are cumulative snapshots — the last one in the file
// represents the total usage for the session. Returns nil if no usage data found.
func (s *Scanner) parseHermesFile(path string) *Record {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var lastUsage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
}
var lastModel string
var lastTimestamp string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
// Fast pre-filter.
if !bytesContains(line, `"usage"`) && !bytesContains(line, `"inputTokens"`) {
continue
}
var entry hermesLine
if err := json.Unmarshal(line, &entry); err != nil {
continue
}
if entry.Usage == nil {
continue
}
// Take the latest usage snapshot (cumulative).
lastUsage = entry.Usage
if entry.Model != "" {
lastModel = entry.Model
}
if entry.Timestamp != "" {
lastTimestamp = entry.Timestamp
}
}
if lastUsage == nil {
return nil
}
if lastUsage.InputTokens == 0 && lastUsage.OutputTokens == 0 {
return nil
}
// Parse timestamp for date.
var date string
if lastTimestamp != "" {
if ts, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil {
date = ts.Local().Format("2006-01-02")
} else if ts, err := time.Parse(time.RFC3339, lastTimestamp); err == nil {
date = ts.Local().Format("2006-01-02")
}
}
if date == "" {
// Fall back to file modification time.
if info, err := os.Stat(path); err == nil {
date = info.ModTime().Local().Format("2006-01-02")
} else {
return nil
}
}
model := lastModel
if model == "" {
model = "unknown"
}
return &Record{
Date: date,
Provider: "hermes",
Model: model,
InputTokens: lastUsage.InputTokens,
OutputTokens: lastUsage.OutputTokens + lastUsage.ThoughtTokens,
CacheReadTokens: lastUsage.CachedReadTokens,
}
}

View File

@@ -0,0 +1,99 @@
package usage
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestParseHermesFile(t *testing.T) {
tmp := t.TempDir()
// Hermes session JSONL with usage_update entries (cumulative snapshots)
content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"claude-sonnet-4-5"}
{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":1000,"outputTokens":200,"cachedReadTokens":500,"thoughtTokens":50}}
{"type":"usage_update","timestamp":"2026-04-10T14:02:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":3000,"outputTokens":600,"cachedReadTokens":1500,"thoughtTokens":100}}
`
filePath := filepath.Join(tmp, "session-001.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseHermesFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.Provider != "hermes" {
t.Errorf("provider = %q, want %q", record.Provider, "hermes")
}
if record.Model != "claude-sonnet-4-5" {
t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5")
}
if record.Date != "2026-04-10" {
t.Errorf("date = %q, want %q", record.Date, "2026-04-10")
}
// Should take the last (cumulative) snapshot
if record.InputTokens != 3000 {
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 3000)
}
// output_tokens + thought_tokens
if record.OutputTokens != 700 {
t.Errorf("output_tokens = %d, want %d (600 + 100)", record.OutputTokens, 700)
}
if record.CacheReadTokens != 1500 {
t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 1500)
}
}
func TestParseHermesFile_NoUsage(t *testing.T) {
tmp := t.TempDir()
content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"test-model"}
{"type":"message","timestamp":"2026-04-10T14:01:00.000Z","content":"hello"}
`
filePath := filepath.Join(tmp, "session-empty.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseHermesFile(filePath)
if record != nil {
t.Errorf("expected nil record for no usage data, got %+v", record)
}
}
func TestParseHermesFile_SingleUsage(t *testing.T) {
tmp := t.TempDir()
content := `{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"hermes-3","usage":{"inputTokens":500,"outputTokens":100,"cachedReadTokens":0,"thoughtTokens":0}}
`
filePath := filepath.Join(tmp, "session-single.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseHermesFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.InputTokens != 500 {
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 500)
}
if record.OutputTokens != 100 {
t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 100)
}
if record.Model != "hermes-3" {
t.Errorf("model = %q, want %q", record.Model, "hermes-3")
}
}

View File

@@ -0,0 +1,154 @@
package usage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
)
// scanOpenClaw reads OpenClaw JSONL session files from
// ~/.openclaw/agents/*/sessions/*.jsonl
// and extracts token usage from assistant message entries.
func (s *Scanner) scanOpenClaw() []Record {
root := openClawSessionRoot()
if root == "" {
return nil
}
// Glob for session files: agents/*/sessions/*.jsonl
pattern := filepath.Join(root, "*", "sessions", "*.jsonl")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("openclaw glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
records := s.parseOpenClawFile(f)
allRecords = append(allRecords, records...)
}
return mergeRecords(allRecords)
}
// openClawSessionRoot returns the OpenClaw agents directory.
func openClawSessionRoot() string {
if openclawHome := os.Getenv("OPENCLAW_HOME"); openclawHome != "" {
dir := filepath.Join(openclawHome, "agents")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir := filepath.Join(home, ".openclaw", "agents")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
return ""
}
// openClawLine represents a line in an OpenClaw JSONL session file.
type openClawLine struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"` // RFC3339
Message *struct {
Role string `json:"role"`
Provider string `json:"provider"`
Model string `json:"model"`
Usage *struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
CacheRead int64 `json:"cacheRead"`
CacheWrite int64 `json:"cacheWrite"`
} `json:"usage"`
} `json:"message"`
}
// parseOpenClawFile extracts token usage records from an OpenClaw session JSONL file.
func (s *Scanner) parseOpenClawFile(path string) []Record {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var records []Record
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
// Fast pre-filter: skip lines that don't contain relevant data.
if !bytesContains(line, `"usage"`) {
continue
}
if !bytesContains(line, `"assistant"`) {
continue
}
var entry openClawLine
if err := json.Unmarshal(line, &entry); err != nil {
continue
}
if entry.Type != "message" || entry.Message == nil || entry.Message.Role != "assistant" || entry.Message.Usage == nil {
continue
}
u := entry.Message.Usage
if u.Input == 0 && u.Output == 0 {
continue
}
// Parse timestamp to get date.
ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
if err != nil {
ts, err = time.Parse(time.RFC3339, entry.Timestamp)
if err != nil {
continue
}
}
model := entry.Message.Model
if model == "" {
model = "unknown"
}
// Construct provider string: if the session has a provider, use "openclaw/<provider>"
// for attribution, but the Record.Provider field should be "openclaw".
provider := "openclaw"
_ = entry.Message.Provider // available but not used in provider field
records = append(records, Record{
Date: ts.Local().Format("2006-01-02"),
Provider: provider,
Model: normalizeOpenClawModel(entry.Message.Provider, model),
InputTokens: u.Input,
OutputTokens: u.Output,
CacheReadTokens: u.CacheRead,
CacheWriteTokens: u.CacheWrite,
})
}
return records
}
// normalizeOpenClawModel returns a model identifier. If the provider is known,
// it prefixes the model name for clarity (e.g. "deepseek/deepseek-chat").
func normalizeOpenClawModel(provider, model string) string {
provider = strings.TrimSpace(provider)
model = strings.TrimSpace(model)
if provider != "" && !strings.Contains(model, "/") {
return provider + "/" + model
}
return model
}

View File

@@ -0,0 +1,93 @@
package usage
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestParseOpenClawFile(t *testing.T) {
tmp := t.TempDir()
// Real OpenClaw session JSONL with session header, model_change, and assistant messages
content := `{"type":"session","version":3,"id":"multica-test","timestamp":"2026-04-11T13:53:05.847Z"}
{"type":"model_change","id":"03c18aae","timestamp":"2026-04-11T13:53:05.855Z","provider":"deepseek","modelId":"deepseek-chat"}
{"type":"message","id":"162ce1b7","parentId":"c90ecabe","timestamp":"2026-04-11T13:53:09.986Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll start by getting the issue details."}],"api":"openai-completions","provider":"deepseek","model":"deepseek-chat","usage":{"input":133,"output":81,"cacheRead":16448,"cacheWrite":0,"totalTokens":16662}}}
{"type":"message","id":"3c063300","parentId":"50e4feb6","timestamp":"2026-04-11T13:53:14.750Z","message":{"role":"assistant","content":[{"type":"text","text":"Let me check the workspace."}],"provider":"deepseek","model":"deepseek-chat","usage":{"input":286,"output":94,"cacheRead":16448,"cacheWrite":0}}}
{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
`
filePath := filepath.Join(tmp, "session.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
records := s.parseOpenClawFile(filePath)
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
r := records[0]
if r.Provider != "openclaw" {
t.Errorf("provider = %q, want %q", r.Provider, "openclaw")
}
if r.Model != "deepseek/deepseek-chat" {
t.Errorf("model = %q, want %q", r.Model, "deepseek/deepseek-chat")
}
if r.InputTokens != 133 {
t.Errorf("input_tokens = %d, want %d", r.InputTokens, 133)
}
if r.OutputTokens != 81 {
t.Errorf("output_tokens = %d, want %d", r.OutputTokens, 81)
}
if r.CacheReadTokens != 16448 {
t.Errorf("cache_read_tokens = %d, want %d", r.CacheReadTokens, 16448)
}
if r.Date != "2026-04-11" {
t.Errorf("date = %q, want %q", r.Date, "2026-04-11")
}
}
func TestParseOpenClawFile_NoUsage(t *testing.T) {
tmp := t.TempDir()
// Session with no assistant messages containing usage
content := `{"type":"session","version":3,"id":"empty-session","timestamp":"2026-04-11T13:53:05.847Z"}
{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
`
filePath := filepath.Join(tmp, "session.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
records := s.parseOpenClawFile(filePath)
if len(records) != 0 {
t.Errorf("expected 0 records, got %d", len(records))
}
}
func TestNormalizeOpenClawModel(t *testing.T) {
tests := []struct {
provider string
model string
want string
}{
{"deepseek", "deepseek-chat", "deepseek/deepseek-chat"},
{"anthropic", "claude-sonnet-4-5", "anthropic/claude-sonnet-4-5"},
{"", "gpt-4o", "gpt-4o"},
{"openai", "openai/gpt-4o", "openai/gpt-4o"}, // already has /
}
for _, tt := range tests {
got := normalizeOpenClawModel(tt.provider, tt.model)
if got != tt.want {
t.Errorf("normalizeOpenClawModel(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
}
}
}

View File

@@ -0,0 +1,122 @@
package usage
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// scanOpenCode reads OpenCode message JSON files from
// ~/.local/share/opencode/storage/message/ses_*/*.json
// and extracts token usage from assistant messages.
func (s *Scanner) scanOpenCode() []Record {
root := openCodeStorageRoot()
if root == "" {
return nil
}
// Glob for message files: storage/message/ses_*/*.json
pattern := filepath.Join(root, "ses_*", "*.json")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("opencode glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
record := s.parseOpenCodeFile(f)
if record != nil {
allRecords = append(allRecords, *record)
}
}
return mergeRecords(allRecords)
}
// openCodeStorageRoot returns the OpenCode message storage directory.
func openCodeStorageRoot() string {
// Check XDG_DATA_HOME first, then fall back to ~/.local/share
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dataHome = filepath.Join(home, ".local", "share")
}
dir := filepath.Join(dataHome, "opencode", "storage", "message")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
return ""
}
// openCodeMessage represents the subset of an OpenCode message JSON file we need.
type openCodeMessage struct {
Role string `json:"role"`
ModelID string `json:"modelID"`
ProviderID string `json:"providerID"`
Time *struct {
Created int64 `json:"created"` // unix milliseconds
} `json:"time"`
Tokens *struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
Reasoning int64 `json:"reasoning"`
Cache *struct {
Read int64 `json:"read"`
Write int64 `json:"write"`
} `json:"cache"`
} `json:"tokens"`
}
// parseOpenCodeFile reads a single OpenCode message JSON file and returns a Record
// if it contains assistant token usage. Returns nil otherwise.
func (s *Scanner) parseOpenCodeFile(path string) *Record {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var msg openCodeMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil
}
// Only count assistant messages with token usage.
if msg.Role != "assistant" || msg.Tokens == nil || msg.Time == nil {
return nil
}
// Skip messages with no meaningful token usage.
if msg.Tokens.Input == 0 && msg.Tokens.Output == 0 {
return nil
}
ts := time.UnixMilli(msg.Time.Created)
date := ts.Local().Format("2006-01-02")
model := msg.ModelID
if model == "" {
model = "unknown"
}
var cacheRead, cacheWrite int64
if msg.Tokens.Cache != nil {
cacheRead = msg.Tokens.Cache.Read
cacheWrite = msg.Tokens.Cache.Write
}
return &Record{
Date: date,
Provider: "opencode",
Model: model,
InputTokens: msg.Tokens.Input,
OutputTokens: msg.Tokens.Output + msg.Tokens.Reasoning,
CacheReadTokens: cacheRead,
CacheWriteTokens: cacheWrite,
}
}

View File

@@ -0,0 +1,141 @@
package usage
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestParseOpenCodeFile(t *testing.T) {
tmp := t.TempDir()
// Real OpenCode message JSON format with token usage
content := `{
"id": "msg_test001",
"sessionID": "ses_test001",
"role": "assistant",
"time": {"created": 1768332037518, "completed": 1768332039410},
"modelID": "claude-sonnet-4-5",
"providerID": "anthropic",
"tokens": {"input": 10916, "output": 5, "reasoning": 100, "cache": {"read": 448, "write": 50}}
}`
filePath := filepath.Join(tmp, "msg_test001.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.Provider != "opencode" {
t.Errorf("provider = %q, want %q", record.Provider, "opencode")
}
if record.Model != "claude-sonnet-4-5" {
t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5")
}
if record.InputTokens != 10916 {
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 10916)
}
// output_tokens + reasoning
if record.OutputTokens != 105 {
t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 105)
}
if record.CacheReadTokens != 448 {
t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 448)
}
if record.CacheWriteTokens != 50 {
t.Errorf("cache_write_tokens = %d, want %d", record.CacheWriteTokens, 50)
}
}
func TestParseOpenCodeFile_UserMessage(t *testing.T) {
tmp := t.TempDir()
// User messages should be ignored (no token usage to report)
content := `{
"id": "msg_user001",
"sessionID": "ses_test001",
"role": "user",
"time": {"created": 1768332037000}
}`
filePath := filepath.Join(tmp, "msg_user001.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record != nil {
t.Errorf("expected nil record for user message, got %+v", record)
}
}
func TestParseOpenCodeFile_NoCache(t *testing.T) {
tmp := t.TempDir()
// Message without cache field
content := `{
"id": "msg_test002",
"sessionID": "ses_test002",
"role": "assistant",
"time": {"created": 1768332037518},
"modelID": "gpt-4o",
"providerID": "openai",
"tokens": {"input": 500, "output": 200, "reasoning": 0}
}`
filePath := filepath.Join(tmp, "msg_test002.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.CacheReadTokens != 0 {
t.Errorf("cache_read_tokens = %d, want 0", record.CacheReadTokens)
}
if record.CacheWriteTokens != 0 {
t.Errorf("cache_write_tokens = %d, want 0", record.CacheWriteTokens)
}
if record.Model != "gpt-4o" {
t.Errorf("model = %q, want %q", record.Model, "gpt-4o")
}
}
func TestParseOpenCodeFile_ZeroTokens(t *testing.T) {
tmp := t.TempDir()
// Message with zero tokens should return nil
content := `{
"id": "msg_test003",
"sessionID": "ses_test003",
"role": "assistant",
"time": {"created": 1768332037518},
"modelID": "test-model",
"tokens": {"input": 0, "output": 0, "reasoning": 0}
}`
filePath := filepath.Join(tmp, "msg_test003.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record != nil {
t.Errorf("expected nil record for zero tokens, got %+v", record)
}
}

View File

@@ -25,8 +25,9 @@ func NewScanner(logger *slog.Logger) *Scanner {
return &Scanner{logger: logger}
}
// Scan reads local JSONL log files for both Claude Code and Codex CLI,
// and returns aggregated usage records keyed by (date, provider, model).
// Scan reads local log files for all supported agent runtimes (Claude Code,
// Codex, OpenCode, OpenClaw, Hermes) and returns aggregated usage records
// keyed by (date, provider, model).
func (s *Scanner) Scan() []Record {
var records []Record
@@ -36,6 +37,15 @@ func (s *Scanner) Scan() []Record {
codexRecords := s.scanCodex()
records = append(records, codexRecords...)
openCodeRecords := s.scanOpenCode()
records = append(records, openCodeRecords...)
openClawRecords := s.scanOpenClaw()
records = append(records, openClawRecords...)
hermesRecords := s.scanHermes()
records = append(records, hermesRecords...)
return records
}