Go library for persisting activity log logs with:
- direct APIs:
Create,Update,Get,GetEventCategories - optional HTTP middleware: Gin and
net/http - optional service-level tracker for business/repository operations
- metadata merge, JSON redaction, and geolocation helpers
- no built-in HTTP handlers or runnable app entrypoint
Repository: https://github.com/PayRam/activity-log
Module path: github.com/PayRam/activity-log
go get github.com/PayRam/activity-logpackage main
import (
"context"
"net/http"
"github.com/PayRam/activity-log/pkg/activitylog"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func run() error {
db, err := gorm.Open(postgres.Open("dsn"), &gorm.Config{})
if err != nil {
return err
}
logger, _ := zap.NewProduction()
client, err := activitylog.New(activitylog.Config{
DB: db,
Logger: logger,
TablePrefix: "",
})
if err != nil {
return err
}
if err := client.AutoMigrate(context.Background()); err != nil {
return err
}
_, err = client.CreateActivityLogs(context.Background(), activitylog.CreateRequest{
SessionID: "session-123",
Method: "POST",
Endpoint: "/api/v1/payment-request",
APIAction: activitylog.APIActionWrite,
APIStatus: activitylog.APIStatusSuccess,
})
if err != nil {
return err
}
status := activitylog.APIStatusError
code := activitylog.HTTPStatusCode(http.StatusInternalServerError)
msg := "downstream failed"
_, err = client.UpdateActivityLogSessionID(context.Background(), activitylog.UpdateRequest{
SessionID: "session-123",
APIStatus: &status,
StatusCode: &code,
Description: &msg,
})
return err
}Recommended production flow is hybrid:
- middleware logs API request/response lifecycle
- service tracker logs internal service/repository operations
- shared
session_idin context correlates both layers
This is useful when one API call triggers multiple service operations.
activity-log/
├── pkg/
│ └── activitylog/ # public API surface
│ ├── activity_log.go # Client + Create/Update/Get APIs
│ ├── types.go # request/response contracts
│ ├── service_tracker.go # service/repository operation tracker
│ ├── geolocation.go # public geolocation API + enrichers
│ ├── metadata.go # public metadata merge helper
│ ├── redact.go # public redaction helper
│ ├── ginmiddleware/ # optional Gin adapter middleware
│ └── httpmiddleware/ # optional net/http adapter middleware
├── internal/ # private implementation details
│ ├── models/ # gorm models + table config
│ ├── repositories/ # gorm query + persistence layer
│ ├── services/ # service layer used by Client
│ ├── middleware/ # shared request/response capture utils
│ └── utils/ # internal helpers (metadata/redact/ids/geolocation engine)
├── go.mod
└── README.md
Dependency direction:
- app imports
pkg/activitylog(and optionalpkg/activitylog/ginmiddleware,pkg/activitylog/httpmiddleware) pkg/activitylogusesinternal/servicesinternal/servicesusesinternal/repositoriesinternal/repositoriesusesinternal/models+gorminternal/*packages are implementation details and not for external imports
activitylog.Config:
DB *gorm.DB(required): gorm database handleLogger *zap.Logger(optional): defaults to production zap loggerTablePrefix string(optional): prefixesactivity_logstable nameTableName string(optional): overrides base table name (for exampleactivity_logs)EventDeriver EventDeriver(optional): derivesEventCategory/EventNameonCreatewhen those fields are missingEventInfoDeriver EventInfoDeriver(optional): derivesEventCategory/EventName/DescriptiononCreateandUpdatewhen those fields are missingAccessResolver AccessResolver(optional): appliesGetaccess scopingConfigProvider ConfigProvider(optional): can override export limit (activity.log.export.limit)MemberResolver MemberResolver(optional): hydratesActivity.MemberinGetProjectResolver ProjectResolver(optional): hydratesActivity.ProjectsinGet
ACTIVITY_LOG_TEST_POSTGRES_DSN: PostgreSQL DSN used by integration tests that need a real databaseGEOLOCATION_PROVIDER_URL: geolocation provider URL template fallback (used whenGeoLookupConfig.ProviderURLTemplateis empty)GEOLOCATION_PROVIDER_NAME: geolocation provider name fallback (used whenGeoLookupConfig.ProviderNameis empty)
Common mapping:
| Library field | Your app (example) |
|---|---|
ProjectIDs |
ProjectIDs |
ProjectResolver |
ProjectResolver |
AccessContext.AllowedProjectIDs |
IDs of projects user can access |
Practical integration rule:
- Treat
ProjectIDsas the scope IDs from your domain model. - Keep your domain naming internally.
- Add a thin adapter only at integration boundaries.
Example adapter setup:
type ProjectService interface {
GetByIDs(ctx context.Context, ids []uint) (map[uint]Project, error)
GetAllowedProjectIDs(ctx context.Context, memberID uint) ([]uint, error)
}
type Project struct {
ID uint
Name string
LogoPath string
}
type projectResolver struct {
projectService ProjectService
}
func (r *projectResolver) GetByIDs(ctx context.Context, ids []uint) (map[uint]activitylog.ProjectInfo, error) {
projects, err := r.projectService.GetByIDs(ctx, ids)
if err != nil {
return nil, err
}
out := make(map[uint]activitylog.ProjectInfo, len(projects))
for id, p := range projects {
out[id] = activitylog.ProjectInfo{
ID: p.ID,
Name: p.Name,
LogoPath: p.LogoPath,
}
}
return out, nil
}
type projectAccessResolver struct {
projectService ProjectService
}
func (r *projectAccessResolver) Resolve(ctx context.Context, memberID uint) (*activitylog.AccessContext, error) {
allowed, err := r.projectService.GetAllowedProjectIDs(ctx, memberID)
if err != nil {
return nil, err
}
return &activitylog.AccessContext{
IsAdmin: false,
AllowedProjectIDs: allowed,
}, nil
}
client, err := activitylog.New(activitylog.Config{
DB: db,
ProjectResolver: &projectResolver{projectService: projectService},
AccessResolver: &projectAccessResolver{projectService: projectService},
})Request mapping example:
projectID := uint(101)
_, err := client.CreateActivityLogs(ctx, activitylog.CreateRequest{
SessionID: "s-1",
Method: "POST",
Endpoint: "/payments",
APIAction: activitylog.APIActionWrite,
APIStatus: activitylog.APIStatusSuccess,
ProjectIDs: []uint{projectID}, // project scope in your app
})Required fields:
SessionID stringMethod stringEndpoint stringAPIAction stringAPIStatus APIStatus
Supported optional fields:
- actor scope:
MemberID *uint,ProjectIDs []uint - result:
StatusCode *HTTPStatusCode,Description *string,APIErrorMsg *string - request info:
IPAddress *string,UserAgent *string,Referer *string - payloads:
RequestBody *string,ResponseBody *string,Metadata *string - classification:
Role *string,EventCategory *string,EventName *string - geolocation:
Country *string,CountryCode *string,Region *string,City *string,Timezone *string,Latitude *float64,Longitude *float64
Notes:
Endpointis stored in DB fieldapi_part.ProjectIDsis stored as JSON/JSONB array.- if
EventCategory/EventNameare not provided, library falls back to URL-derived resource segments (suspicious/probe-style paths and static asset routes are ignored) - if
Config.EventInfoDeriveris provided, it is used first for category/name/description fallback - if
Config.EventDeriveris provided, it is used for category/name fallback when event info deriver does not provide those values
Event deriver options:
DefaultEventDeriver: default endpoint-based fallbackDefaultEventInfoDeriver: default endpoint/method/status-based fallback including descriptionNewCoreLikeEventDeriver: helper that approximatestest/corederiveEventInfostyle (CATEGORY_ACTION)NewCoreLikeEventInfoDeriver: helper that approximatestest/corederiveEventInfostyle (CATEGORY_ACTION) and description textCoreLikeEventDeriverConfig.StrictTableMatch: whentrueandTableNamesis set, only matched table/service names are emitted as categories; unknown routes are ignored
Example:
client, err := activitylog.New(activitylog.Config{
DB: db,
EventInfoDeriver: activitylog.NewCoreLikeEventInfoDeriver(activitylog.CoreLikeEventDeriverConfig{
BasePath: "/api/v1",
TableNames: []string{"members", "payment_requests", "withdrawals"},
StrictTableMatch: true,
}),
})Required field:
SessionID string
Supported updatable fields:
- actor scope:
MemberID *uint,ProjectIDs []uint - route/action/status:
Method *string,Endpoint *string,APIAction *string,APIStatus *APIStatus - result:
StatusCode *HTTPStatusCode,Description *string,APIErrorMsg *string - request info:
IPAddress *string,UserAgent *string,Referer *string - payloads:
RequestBody *string,ResponseBody *string,Metadata *string - classification:
Role *string,EventCategory *string,EventName *string - geolocation:
Country *string,CountryCode *string,Region *string,City *string,Timezone *string,Latitude *float64,Longitude *float64
Legend:
R: requiredO: optional-: not applicable
Core fields:
| Field | Type | Create | Update | Notes |
|---|---|---|---|---|
SessionID |
string |
R |
R |
Update key (session_id) |
Method |
string / *string |
R |
O |
HTTP method or service method |
Endpoint |
string / *string |
R |
O |
Stored in DB as api_part |
APIAction |
string / *string |
R |
O |
Use constants (READ/WRITE/DELETE) |
APIStatus |
string / *string |
R |
O |
Use constants (SUCCESS/DENIED/ERROR) |
StatusCode |
*HTTPStatusCode |
O |
O |
Use net/http constants (for example http.StatusOK) |
Description |
*string |
O |
O |
Human-readable message |
APIErrorMsg |
*string |
O |
O |
Error text if any |
Actor and classification:
| Field | Type | Create | Update | Notes |
|---|---|---|---|---|
MemberID |
*uint |
O |
O |
Actor member id |
ProjectIDs |
[]uint |
O |
O |
nil slice means no update; empty/non-empty slice updates DB JSON array |
Role |
*string |
O |
O |
Actor role |
EventCategory |
*string |
O |
O |
Event grouping |
EventName |
*string |
O |
O |
Event name |
Request/response context:
| Field | Type | Create | Update | Notes |
|---|---|---|---|---|
IPAddress |
*string |
O |
O |
Client/source IP |
UserAgent |
*string |
O |
O |
Caller user-agent |
Referer |
*string |
O |
O |
HTTP referer |
RequestBody |
*string |
O |
O |
Consider redaction |
ResponseBody |
*string |
O |
O |
Consider redaction |
Metadata |
*string |
O |
O |
JSON string recommended |
Geolocation:
| Field | Type | Create | Update | Notes |
|---|---|---|---|---|
Country |
*string |
O |
O |
Country name |
CountryCode |
*string |
O |
O |
ISO-like code |
Region |
*string |
O |
O |
Region/state |
City |
*string |
O |
O |
City |
Timezone |
*string |
O |
O |
Timezone |
Latitude |
*float64 |
O |
O |
Geo latitude |
Longitude |
*float64 |
O |
O |
Geo longitude |
Pointer semantics for update:
nilpointer field: no change- non-
nilpointer field: update to provided value - update
ProjectIDs == nil: no change - update
len(ProjectIDs) == 0with non-nil slice value ([]uint{}): set empty JSON array ([]) - update
ProjectIDspopulated: set provided IDs
Implementation detail:
- update is by
session_id, inside a DB transaction with row lock - GORM struct updates omit zero values for non-pointer fields
Supported filters:
- arrays:
StatusCodes(query key:statusCoderepeated),EventCategories,Methods,EventNames,IDS,MemberIDs,ProjectIDs,SessionIDs,APIStatuses,IPAddresses,Countries,Roles - exact/single:
Search - pagination/time:
Limit,Offset,SortBy,Order,GreaterThanID,LessThanID,CreatedAfter,CreatedBefore,UpdatedAfter,UpdatedBefore,StartDate,EndDate - internal flag:
Export
Behavior:
- default
Limitis100 - export mode can use config key
activity.log.export.limit - if
AccessResolveris configured, non-admin scope is enforced
Returns distinct non-null event categories.
Statuses:
APIStatusSuccess(SUCCESS)APIStatusDenied(DENIED)APIStatusError(ERROR)
Actions:
APIActionRead(READ)APIActionWrite(WRITE)APIActionDelete(DELETE)APIActionUnknown(UNKNOWN)
Both middleware packages follow the same model:
- create log entry at request start
- capture status/response
- update same entry by
session_id
Packages:
- Gin:
github.com/PayRam/activity-log/pkg/activitylog/ginmiddleware - net/http:
github.com/PayRam/activity-log/pkg/activitylog/httpmiddleware
Shared config fields:
Client *activitylog.Client(required)Logger *zap.LoggerCaptureRequestBody boolCaptureResponseBody boolMaxBodyBytes int64Redact func([]byte) []byteResponseRedact func([]byte) []byteSkipPaths []stringSkip func(*gin.Context) bool(Gin)Skip func(*http.Request) bool(net/http)SessionIDHeader stringSessionIDFunc func(*gin.Context) string(Gin)SessionIDFunc func(*http.Request) string(net/http)IPExtractor func(*gin.Context) string(Gin)IPExtractor func(*http.Request) string(net/http)GeoLookup *activitylog.GeoLookup(optional): enriches request withCountry/City/...from IPCreateEnricher func(*gin.Context, *activitylog.CreateRequest)(Gin)CreateEnricher func(*http.Request, *activitylog.CreateRequest)(net/http)UpdateEnricher func(*gin.Context, *activitylog.UpdateRequest, *ginmiddleware.CapturedResponse)(Gin)UpdateEnricher func(*http.Request, *activitylog.UpdateRequest, *httpmiddleware.CapturedResponse)(net/http)Async boolOnError func(error)
Optional global wrapper (same shape as Middleware() with no args):
- Gin: call
ginmiddleware.SetDefaultConfig(cfg)once, then useginmiddleware.Middleware() - net/http: call
httpmiddleware.SetDefaultConfig(cfg)once, then usehttpmiddleware.Middleware() - call
ResetDefaultConfig()in tests to avoid cross-test leakage
Important: this global wrapper trades convenience for weaker test isolation. Prefer explicit DI (Middleware(Config{...})) for production wiring.
Use SkipPaths or Skip for sensitive routes:
/signin/signup/oauth/token- password reset/change endpoints
router.Use(ginmiddleware.Middleware(ginmiddleware.Config{
Client: client,
CaptureRequestBody: true,
CaptureResponseBody: true,
MaxBodyBytes: 1 * 1024 * 1024,
GeoLookup: activitylog.NewGeoLookup(activitylog.GeoLookupConfig{}),
SkipPaths: []string{"/signin", "/signup"},
Redact: activitylog.RedactDefaultJSONKeys,
ResponseRedact: activitylog.RedactDefaultJSONKeys,
CreateEnricher: func(c *gin.Context, req *activitylog.CreateRequest) {
// set MemberID / Role / EventName, etc.
},
UpdateEnricher: func(c *gin.Context, req *activitylog.UpdateRequest, resp *ginmiddleware.CapturedResponse) {
// add description / metadata, etc.
},
}))mw := httpmiddleware.Middleware(httpmiddleware.Config{
Client: client,
CaptureRequestBody: true,
CaptureResponseBody: true,
MaxBodyBytes: 1 * 1024 * 1024,
GeoLookup: activitylog.NewGeoLookup(activitylog.GeoLookupConfig{}),
SkipPaths: []string{"/signin", "/signup"},
Redact: activitylog.RedactDefaultJSONKeys,
ResponseRedact: activitylog.RedactDefaultJSONKeys,
CreateEnricher: func(r *http.Request, req *activitylog.CreateRequest) {
// set MemberID / Role / EventName, etc.
},
UpdateEnricher: func(r *http.Request, req *activitylog.UpdateRequest, resp *httpmiddleware.CapturedResponse) {
// add description / metadata, etc.
},
})Use built-in helpers when you want geolocation outside middleware hooks.
GeoLookupConfig fields:
ProviderURLTemplate stringProviderName stringTimeout time.DurationCacheTTL time.DurationLogger *zap.LoggerHTTPClient *http.Client
lookup := activitylog.NewGeoLookup(activitylog.GeoLookupConfig{
ProviderURLTemplate: "https://ipwhois.app/json/%s", // optional
ProviderName: "ipwhois.io", // optional
Timeout: 5 * time.Second, // optional
CacheTTL: 24 * time.Hour, // optional
})
location := lookup.Lookup("8.8.8.8")
if location != nil {
activitylog.EnrichCreateRequestWithLocation(&createReq, location)
}Environment variables (used if config values are empty):
GEOLOCATION_PROVIDER_URLGEOLOCATION_PROVIDER_NAME
Use service tracker to log business/service/repository operations:
tracker := activitylog.NewServiceTracker(activitylog.ServiceTrackerConfig{
Client: client,
})
memberID := uint(42)
err := tracker.Track(ctx, activitylog.ServiceOperation{
Name: "PaymentService.CreateNewPaymentRequest",
MemberID: &memberID,
APIAction: activitylog.APIActionWrite,
}, func(ctx context.Context) error {
return repo.CreateActivityLogs(ctx, memberID)
})ServiceTrackerConfig fields:
Client *activitylog.Client(required)Logger *zap.LoggerAsync boolOnError func(error)CreateEnricher func(context.Context, *activitylog.CreateRequest)UpdateEnricher func(context.Context, *activitylog.UpdateRequest, *activitylog.ServiceResult)
ServiceOperation supported fields:
Name stringSessionID stringMemberID *uintProjectIDs []uintMethod stringEndpoint stringAPIAction stringDescription *stringMetadata *stringRole *stringEventCategory *stringEventName *string
Service metadata written by tracker:
source: "SERVICE"operationNameoperationTrail: ordered list of nested operations
If request middleware already set session id in context, tracker reuses it.
Use this helper to enrich existing JSON metadata without overwriting all fields.
type apiMeta struct {
ServiceName *string `json:"serviceName,omitempty"`
ServiceStatus *string `json:"serviceStatus,omitempty"`
}
req.Metadata = activitylog.MergeMetadata(req.Metadata, apiMeta{
ServiceName: req.EventName,
ServiceStatus: req.APIStatus,
})If existing is non-JSON, it is preserved under rawMetadata.
Body capture is optional and should be enabled only where needed.
Built-in redaction helper:
RedactDefaultJSONKeysmasks keys:password,token,secret,api_key,apiKey,private_key,privateKey,access_token,refresh_token,authorization,Authorization- matching is case-insensitive and separator-insensitive (
accessToken,ACCESS_TOKEN,access-tokenall match)
Important:
- redaction is JSON key-based
- non-JSON payloads are unchanged
- request/response headers are not captured/redacted by default middleware
Package-level errors:
ErrUnauthorized
Call once during startup:
if err := client.AutoMigrate(ctx); err != nil {
// handle error
}Table name is activity_logs unless you set Config.TablePrefix.
If your app uses a different table name, set:
Config.TableNameto override the base name (example:activity_logs)Config.TablePrefixif needed (example:core_)
Example result:
TableName: "activity_logs"=>activity_logsTablePrefix: "core_", TableName: "activity_logs"=>core_activity_logs
Integration tests that touch PostgreSQL require:
ACTIVITY_LOG_TEST_POSTGRES_DSN
go test ./...
go test ./... -cover