Skip to content

Commit 29deedb

Browse files
committed
my metrics and test for like for like field values across CallContext
and CallContextLight so they can never diverge.
1 parent 726f5b5 commit 29deedb

12 files changed

Lines changed: 370 additions & 31 deletions

File tree

‎obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3208,6 +3208,16 @@ object SwaggerDefinitionsJSON {
32083208
lazy val metricsJsonV600 = MetricsJsonV600(
32093209
metrics = List(metricJsonV600)
32103210
)
3211+
lazy val aggregateMetricJsonV600 = AggregateMetricJsonV600(
3212+
count = 7076,
3213+
average_response_time = 65.21,
3214+
minimum_response_time = 1,
3215+
maximum_response_time = 9039,
3216+
distinct_user_count = 41,
3217+
distinct_consumer_count = 12,
3218+
consent_call_count = 1024,
3219+
distinct_consent_count = 9
3220+
)
32113221

32123222
lazy val branchJsonPut = BranchJsonPutV210("gh.29.fi", "OBP",
32133223
addressJsonV140,

‎obp-api/src/main/scala/code/api/util/ApiSession.scala‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,25 @@ case class CallContext(
2929
dauthRequestPayload: Option[JSONFactoryDAuth.PayloadOfJwtJSON] = None, //Never update these values inside the case class !!!
3030
dauthResponseHeader: Option[String] = None,
3131
spelling: Option[String] = None,
32+
// The AUTHENTICATED principal. Not always a person: under a consent this is the
33+
// consent's own shadow user. Stored data (metric rows, created_by_user_id columns)
34+
// always records this id — the human is resolved at read time via the consent table.
3235
user: Box[User] = Empty,
36+
// The human who CREATED the consent this request runs under. Populated only by the
37+
// OBP-native consent path (applyConsentRulesCommon) from the consent JWT's
38+
// createdByUserId claim, resolved against the users table. For OBP-native consents
39+
// the creator is the granting human (they create their own consent in the Portal).
40+
// Not set by Berlin Group / UK flows, where the consent may be created by a TPP flow
41+
// with no human logged in — see `consenter` for those.
42+
// Read via humanUser / effectiveHumanUserId, where it takes precedence over consenter.
3343
onBehalfOfUser: Box[User] = Empty,
44+
// The human (PSU) who AUTHORISED the consent this request runs under — the owner of
45+
// record, from the consent table's userId (bound by updateConsentUser during the
46+
// authorise ceremony). Populated by the Berlin Group and UK consent paths, whose
47+
// consents are created by TPP flows and only gain their human at authorisation.
48+
// The UK ownership check (checkUKConsent) compares the consent's userId against this.
49+
// In practice onBehalfOfUser and consenter are never both set: each consent standard
50+
// populates the one whose source is authoritative for it.
3451
consenter: Box[User] = Empty,
3552
consumer: Box[Consumer] = Empty,
3653
ipAddress: String = "",
@@ -94,7 +111,9 @@ case class CallContext(
94111
* `user` is not always a person: a consent resolves to a shadow user that exists only for that
95112
* consent (Berlin Group, OBP-native, and -- since UK consents moved to the same model -- UK too).
96113
* Anything that must name a human rather than a principal reads this instead: the CBS adapter,
97-
* which tells the core banking system who is asking, and metric attribution.
114+
* which tells the core banking system who is asking, and the consent ownership checks.
115+
* Stored data (metric rows included) always carries the authenticated principal; the human is
116+
* resolved at read time via the consent table (see effectiveHumanUserId).
98117
*/
99118
def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user)
100119

@@ -159,12 +178,13 @@ case class CallContext(
159178
CallContextLight(
160179
gatewayLoginRequestPayload = this.gatewayLoginRequestPayload,
161180
gatewayLoginResponseHeader = this.gatewayLoginResponseHeader,
162-
// Metrics name the human, not the principal. A consent's shadow user would record a per-consent
163-
// UUID and an empty username, which is what Berlin Group and OBP-native traffic has always
164-
// looked like on the metrics table; the consent itself stays identifiable via
165-
// consentReferenceId below.
166-
userId = this.humanUser.map(_.userId).toOption,
167-
userName = this.humanUser.map(_.name).toOption,
181+
// Like for like with CallContext: userId/userName are the AUTHENTICATED principal
182+
// (CallContext.user), never a resolved human. Under a consent that principal is the
183+
// consent's own shadow user (a per-consent UUID with an empty name) — the on-behalf-of
184+
// human is not stored here but resolved at read time via the consent table
185+
// (consentReferenceId below -> consent.userId), see CallContext.effectiveHumanUserId.
186+
userId = this.user.map(_.userId).toOption,
187+
userName = this.user.map(_.name).toOption,
168188
consumerId = this.consumer.map(_.consumerId.get).toOption,
169189
appName = this.consumer.map(_.name.get).toOption,
170190
developerEmail = this.consumer.map(_.developerEmail.get).toOption,

‎obp-api/src/main/scala/code/api/util/OBPParam.scala‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ case class OBPConsentReferenceId(value: String) extends OBPQueryParam
7070
// PeerTrust.Resolution.mode on the metric row: "direct", "forwarded" or "none".
7171
case class OBPCertificateTrust(value: String) extends OBPQueryParam
7272
case class OBPUserId(value: String) extends OBPQueryParam
73+
// Multiple user ids, matched with SQL IN — used by self-service endpoints that lock the
74+
// user filter to a server-resolved set (e.g. /my/metrics: the human plus their consent-agents).
75+
case class OBPUserIds(values: List[String]) extends OBPQueryParam
7376
case class ProviderProviderId(value: String) extends OBPQueryParam
7477
case class OBPStatus(value: String) extends OBPQueryParam
7578
case class OBPBankId(value: String) extends OBPQueryParam

‎obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -666,10 +666,13 @@ object Http4s600 {
666666
}
667667
(obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(
668668
APIMetrics.applyMetricsFromDateDefault(httpParams), cc.callContext)
669-
aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, false) map {
669+
// isNewVersion = true: v6 is include_* style (exclude_* is rejected above). With
670+
// false the include_app_names / include_url_patterns /
671+
// include_implemented_by_partial_functions filters were silently ignored.
672+
aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, true) map {
670673
APIUtil.unboxFullOrFail(_, callContext, GetAggregateMetricsError)
671674
}
672-
} yield JSONFactory300.createAggregateMetricJson(aggregateMetrics)
675+
} yield JSONFactory600.createAggregateMetricJsonV600(aggregateMetrics)
673676
}
674677
}
675678

@@ -7516,9 +7519,18 @@ object Http4s600 {
75167519
|
75177520
|15 http_status_code (if null ignore) - Filter by HTTP status code. eg: http_status_code=200 returns only successful calls, http_status_code=500 returns server errors
75187521
|
7522+
|**Response fields added in v6.0.0:**
7523+
|
7524+
|- `distinct_user_count` - distinct humans behind the calls. Calls made under a Consent
7525+
|(e.g. by an agent or TPP) are attributed to the granting (on-behalf-of) user resolved via
7526+
|the consent table, not to the consent's technical shadow user. Anonymous calls are excluded.
7527+
|- `distinct_consumer_count` - distinct Consumers (apps) that made calls.
7528+
|- `consent_call_count` - calls that arrived under a Consent.
7529+
|- `distinct_consent_count` - distinct Consents exercised in the window.
7530+
|
75197531
""".stripMargin,
75207532
EmptyBody,
7521-
aggregateMetricsJSONV300,
7533+
aggregateMetricJsonV600,
75227534
List(
75237535
AuthenticatedUserIsRequired,
75247536
UserHasMissingRoles,

‎obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,20 @@ case class MetricJsonV600(
479479
)
480480
case class MetricsJsonV600(metrics: List[MetricJsonV600])
481481

482+
case class AggregateMetricJsonV600(
483+
count: Int,
484+
average_response_time: Double,
485+
minimum_response_time: Double,
486+
maximum_response_time: Double,
487+
// Distinct humans: consent-borne calls are attributed to the granting (on-behalf-of)
488+
// user via the consent table, not to the consent's technical shadow user.
489+
distinct_user_count: Int,
490+
distinct_consumer_count: Int,
491+
// Calls made under a consent, and the number of distinct consents exercised.
492+
consent_call_count: Int,
493+
distinct_consent_count: Int
494+
)
495+
482496
case class CacheNamespaceJsonV600(
483497
prefix: String,
484498
description: String,
@@ -1746,6 +1760,23 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
17461760
createMetricsJsonV600(metrics, lookupMap)
17471761
}
17481762

1763+
// Same list shape as JSONFactory300.createAggregateMetricJson (a single-element array),
1764+
// extended with the distinct/consent counts introduced in v6.0.0.
1765+
def createAggregateMetricJsonV600(aggregateMetrics: List[code.metrics.AggregateMetrics]): List[AggregateMetricJsonV600] = {
1766+
aggregateMetrics.map(aggregateMetric =>
1767+
AggregateMetricJsonV600(
1768+
aggregateMetric.totalCount,
1769+
aggregateMetric.avgResponseTime,
1770+
aggregateMetric.minResponseTime,
1771+
aggregateMetric.maxResponseTime,
1772+
aggregateMetric.distinctUserCount,
1773+
aggregateMetric.distinctConsumerCount,
1774+
aggregateMetric.consentCallCount,
1775+
aggregateMetric.distinctConsentCount
1776+
)
1777+
)
1778+
}
1779+
17491780
def createBankJSON600(
17501781
bank: Bank,
17511782
attributes: List[BankAttributeTrait] = Nil

‎obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,8 +1134,13 @@ object Http4s700 {
11341134
cc = Some(cc)) {
11351135
identityParams.isEmpty
11361136
}
1137+
// "My" spans the delegation family: the human plus every agent user minted
1138+
// from a Consent they granted (metric rows record the authenticated principal,
1139+
// so an agent's calls sit under the agent's own user id). Resolve up to the
1140+
// human, then fan down — both via server-written columns only.
11371141
(metrics, _) <- APIMetrics.getMetricsFromHttpParams(
1138-
httpParams, cc.callContext, lockedUserId = Some(user.userId))
1142+
httpParams, cc.callContext,
1143+
lockedUserIds = Some(humanAndAgentUserIds(cc.effectiveHumanUserId)))
11391144
} yield JSONFactory600.createMetricsJsonV600(metrics)
11401145
}
11411146
}
@@ -1148,10 +1153,13 @@ object Http4s700 {
11481153
"Get Metrics (My)",
11491154
s"""Get the API metrics rows of the currently authenticated user — a record of each REST API call this user has made.
11501155
|
1151-
|No role is required: this endpoint only ever returns the logged in user's own calls.
1156+
|No role is required: this endpoint only ever returns calls belonging to the logged in user —
1157+
|their own calls, plus calls made by agent users minted from Consents this user granted
1158+
|(e.g. an AI agent calling on their behalf). Called under such a Consent, it returns the
1159+
|same family of calls, resolved through the granting user.
11521160
|The identity filter parameters accepted by `GET /management/metrics` (`user_id`, `username`, `email`,
11531161
|`provider_provider_id`, `anon`) are NOT supported here and are rejected with an error —
1154-
|the user filter is always the current user.
1162+
|the user filter is always the current user's delegation family.
11551163
|
11561164
|**NOTE: Automatic from_date Default**
11571165
|

‎obp-api/src/main/scala/code/metrics/APIMetrics.scala‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,22 +52,29 @@ object APIMetrics extends SimpleInjector {
5252

5353
// One shared fetch path for metrics-reading endpoints: builds OBPQueryParams
5454
// from the http params (with the from_date default applied) and runs the query.
55-
// lockedUserId pins the user filter server-side (for self-service endpoints
55+
// lockedUserIds pins the user filter server-side (for self-service endpoints
5656
// like GET /my/metrics); when set it overrides anything in httpParams.
5757
def getMetricsFromHttpParams(
5858
httpParams: List[HTTPParam],
5959
callContext: Option[CallContext],
60-
lockedUserId: Option[String] = None
60+
lockedUserIds: Option[List[String]] = None
6161
): Future[(List[APIMetric], Option[CallContext])] = {
62-
val effectiveParams = lockedUserId match {
63-
case Some(userId) =>
64-
httpParams.filterNot(_.name == "user_id") :+ HTTPParam("user_id", List(userId))
62+
// The lock replaces any caller-supplied user filter outright: the ids are
63+
// server-resolved (e.g. the human plus their consent-agents for /my/metrics)
64+
// and nothing from the request may widen or narrow them.
65+
val effectiveParams = lockedUserIds match {
66+
case Some(_) => httpParams.filterNot(_.name == "user_id")
6567
case None => httpParams
6668
}
6769
for {
6870
(obpQueryParams, cc) <- createQueriesByHttpParamsFuture(
6971
applyMetricsFromDateDefault(effectiveParams), callContext)
70-
metrics <- Future(apiMetrics.vend.getAllMetrics(obpQueryParams))
72+
lockedParams = lockedUserIds match {
73+
case Some(userIds) =>
74+
code.api.util.OBPUserIds(userIds) :: obpQueryParams.filterNot(_.isInstanceOf[code.api.util.OBPUserId])
75+
case None => obpQueryParams
76+
}
77+
metrics <- Future(apiMetrics.vend.getAllMetrics(lockedParams))
7178
} yield (metrics, cc)
7279
}
7380

@@ -194,7 +201,15 @@ case class AggregateMetrics(
194201
totalCount: Int,
195202
avgResponseTime: Double,
196203
minResponseTime: Double,
197-
maxResponseTime: Double
204+
maxResponseTime: Double,
205+
// Distinct humans behind the calls: consent-borne rows are attributed to the granting
206+
// (on-behalf-of) user via the consent table, mirroring CallContext.effectiveHumanUserId.
207+
distinctUserCount: Int,
208+
distinctConsumerCount: Int,
209+
// Calls that arrived under a consent (metric.consent_reference_id not null), and how many
210+
// distinct consents were exercised in the window.
211+
consentCallCount: Int,
212+
distinctConsentCount: Int
198213
)
199214

200215
case class TopApi(

‎obp-api/src/main/scala/code/metrics/DoobieMetricsQueries.scala‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,23 +67,35 @@ object DoobieMetricsQueries {
6767
val toTs = new java.sql.Timestamp(toDate.getTime)
6868

6969
// Build dynamic WHERE conditions
70+
// Consent-borne calls are attributed to the granting (on-behalf-of) human via the consent
71+
// table (COALESCE below) — see MappedMetrics.getAllAggregateMetricsBox for the rationale.
72+
// The consent side of the join is unique-indexed on consent_reference_id (no row fan-out).
7073
val baseQuery = fr"""
71-
SELECT count(*), avg(duration), min(duration), max(duration)
72-
FROM metric
74+
SELECT count(*), avg(duration), min(duration), max(duration),
75+
count(DISTINCT CASE WHEN COALESCE(c.muserid, m.userid) <> 'null' THEN COALESCE(c.muserid, m.userid) END),
76+
count(DISTINCT CASE WHEN m.consumerid <> '' AND m.consumerid <> 'null' THEN m.consumerid END),
77+
count(NULLIF(m.consent_reference_id, '')),
78+
count(DISTINCT NULLIF(m.consent_reference_id, ''))
79+
FROM metric m
80+
LEFT JOIN mappedconsent c ON m.consent_reference_id = c.consent_reference_id
7381
WHERE date_c >= $fromTs
7482
AND date_c <= $toTs
7583
"""
7684

7785
val conditions = buildFilterConditions(filters, isNewVersion)
7886
val fullQuery = baseQuery ++ conditions
7987

80-
fullQuery.query[(Long, Option[Double], Option[Double], Option[Double])].to[List].map { rows =>
81-
rows.map { case (count, avgOpt, minOpt, maxOpt) =>
88+
fullQuery.query[(Long, Option[Double], Option[Double], Option[Double], Long, Long, Long, Long)].to[List].map { rows =>
89+
rows.map { case (count, avgOpt, minOpt, maxOpt, distinctUsers, distinctConsumers, consentCalls, distinctConsents) =>
8290
AggregateMetrics(
8391
count.toInt,
8492
avgOpt.map(d => BigDecimal(d).setScale(2, BigDecimal.RoundingMode.HALF_UP).toDouble).getOrElse(0.0),
8593
minOpt.getOrElse(0.0),
86-
maxOpt.getOrElse(0.0)
94+
maxOpt.getOrElse(0.0),
95+
distinctUsers.toInt,
96+
distinctConsumers.toInt,
97+
consentCalls.toInt,
98+
distinctConsents.toInt
8799
)
88100
}
89101
}

0 commit comments

Comments
 (0)